feat: 重构问问微信并完善知识库增量检索

统一问问微信与 Agent Hub 的查询链路
完善查询覆盖度、新鲜度和部分结果表达,避免索引滞后产生错误结论
基于会话实现真正的增量追新与历史补齐
支持后台同步、取消恢复、重启续传以及同步期间继续查询
优化知识库跨会话检索、同步状态、进度展示和侧栏布局
This commit is contained in:
Wxw-Gu
2026-09-11 17:44:48 +08:00
parent 0c4932d740
commit ab3b3731b7
69 changed files with 8317 additions and 898 deletions
+48
View File
@@ -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` 元数据。 每条消息都会返回 `messageType`(`text`、`image`、`voice`、`video`、`file`、`link`、`sticker`、`system` 或 `other`)。非文本消息不会伪造 `text`;可识别的图片、视频、贴纸和文件会返回不含密钥或本地路径的 `attachment` 元数据。
`conversation-overview` 同时返回 `sourceCoverage` 与 `selection`:前者描述时间范围内源消息是否完整及 `sourceMessageCount`,后者描述从源消息中选出的 Evidence 数量及是否抽样。`evidence` 最终按 `timestamp` 升序返回,`messageRef` 是唯一推荐的消息引用。 `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` 直读聊天数据库,不受索引新鲜度影响。
+2 -2
View File
@@ -28,12 +28,12 @@ AI 回答后,你可以继续查看它参考了哪些聊天内容、这些内
来源覆盖受时间范围、会话范围、索引状态和可读媒体影响。例如: 来源覆盖受时间范围、会话范围、索引状态和可读媒体影响。例如:
- Knowledge 正在同步时,新的分析会被暂停; - Knowledge 还没追到最新时,跨会话检索只覆盖到索引当前的时间点,答案会标注这个范围;
- 语音没有转写时,AI 可能只能看到消息类型; - 语音没有转写时,AI 可能只能看到消息类型;
- 图片无法读取或未启用图片理解时,AI 不应声称知道图片内容; - 图片无法读取或未启用图片理解时,AI 不应声称知道图片内容;
- 你只选择了一个群,答案不会自动代表所有聊天。 - 你只选择了一个群,答案不会自动代表所有聊天。
看到“可能遗漏”或“部分覆盖”时,扩大范围、先完成同步或检查原始媒体后再问。 Knowledge 在后台同步时**不会**暂停分析:你仍然可以提问,只是答案基于当前已可用的覆盖范围。看到“可能遗漏”或“部分覆盖”时,扩大范围、等同步追上或检查原始媒体后再问。
## 这不是事实保证 ## 这不是事实保证
+13
View File
@@ -41,6 +41,19 @@
你可以点击来源回到档案中的原始消息。产品内部将这些信息称为 Evidence、Citation 和 Search Trace,用户可以把它们理解为“依据、来源标记和查找过程”。详见[如何核对 AI 的回答来源](../concepts/answer-sources.md)。 你可以点击来源回到档案中的原始消息。产品内部将这些信息称为 Evidence、Citation 和 Search Trace,用户可以把它们理解为“依据、来源标记和查找过程”。详见[如何核对 AI 的回答来源](../concepts/answer-sources.md)。
## 查找过程和跳回原消息
查找过程中,界面依次显示真实阶段:**理解问题 → 查找相关聊天 → 整理证据 → 生成回答**。跨会话、大范围检索更慢时,副提示会写明“正在搜索较大范围的聊天记录…”。阶段只在真正进入下一步时前进,不使用定时器或百分比伪造进度。
查找结束后,界面给出耗时拆解:**总耗时**,以及其中分别花在 **AI 生成** 和 **本地查询** 上的时间。这样你能判断慢在哪——是模型在写答案,还是本机还在翻聊天记录。
点击来源卡片的 **“跳转到原聊天”** 会真的打开对应会话并定位到那条消息:
- 群聊来源打开的是那个群,而不是群里某个联系人;
- 会加载该消息前后的上下文,并滚动到它、短暂高亮;
- 只加载目标消息附近的一段,不会把整个会话历史全部读出来;
- 如果这条消息已经不在本地(例如已被删除),界面会明确说明“已打开对应会话,但暂时无法定位原消息”,不会假装跳转成功。
## 什么时候不要直接相信答案 ## 什么时候不要直接相信答案
- 来源很少,或时间范围与问题不一致; - 来源很少,或时间范围与问题不一致;
+24 -1
View File
@@ -13,7 +13,30 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
- **建立本地知识库**:第一次读取当前账号的可检索聊天; - **建立本地知识库**:第一次读取当前账号的可检索聊天;
- **同步最新记录**:已有索引时,只补充新增或变化的内容。 - **同步最新记录**:已有索引时,只补充新增或变化的内容。
同步会在后台运行,完成后页面显示已索引消息、知识片段和磁盘占用。同步期间暂不能开始新的 AI 分析;同步异常时,旧索引仍可能可以继续使用。 同步在后台运行,**期间仍然可以正常提问和分析**,不会被禁用。索引还没追完时,答案会基于当前已经可用的部分给出,并在界面标注覆盖范围。
知识库卡片同时显示两组互相独立的信息:
- **规模**:已索引消息、知识片段、磁盘占用——说明索引有多大;
- **状态与本轮进度**:说明索引现在处于什么状态、这一轮同步在做什么(扫了多少、真正新增了多少、处理到第几个会话)。
`最新索引` 只表示索引已经覆盖到聊天记录的哪个时间点,**不等于**整库已经建完;进度里的计数是**本轮**的数字,不是全部历史的总数。
### 状态怎么读
| 状态 | 含义 |
| ---- | ---- |
| 可用 · 已追至最新 | 索引已覆盖到聊天记录的最新位置,可以直接用 |
| 可用 · 正在追新 | 索引可用,正在后台补充最近新增的消息 |
| 可用 · 正在补齐历史 | 索引可用,正在后台补齐较早的历史内容 |
| 可用 · 同步已取消 | 索引仍然可用;上一轮同步被取消,已建立的部分保留 |
| 可用 · 更新失败 | 索引仍然可用;上一轮同步出错,可以稍后重试 |
只有确实追平、且没有待补齐内容时才会出现“已追至最新”。索引不可查询时不会显示“可用”。
### 取消和继续
同步过程中可以点击 **取消同步**(点击后显示“正在取消…”)。取消只结束当前这一轮,不会删除已经建立的索引,也不会回滚已完成的部分;下次同步会从上次停下的位置继续,不需要从头重扫。中断过的索引仍然可以正常搜索。
## 账号隔离 ## 账号隔离
+144
View File
@@ -157,14 +157,31 @@ import { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-
import { VoiceBatchService } from './voice-pipeline/voice-batch-service' import { VoiceBatchService } from './voice-pipeline/voice-batch-service'
import type { VoiceBatchRequest, VoiceMessageReference } from '../shared/voice-recognition' import type { VoiceBatchRequest, VoiceMessageReference } from '../shared/voice-recognition'
import type { AiSearchPipelineRequest } from '../shared/ai-search' import type { AiSearchPipelineRequest } from '../shared/ai-search'
import type {
AskWechatConfig,
AskWechatQueryRequest,
AskWechatQueryResult
} from '../shared/query-agent'
import type { KnowledgeSearchIpcRequest, KnowledgeSearchIpcResult } from '../shared/knowledge' import type { KnowledgeSearchIpcRequest, KnowledgeSearchIpcResult } from '../shared/knowledge'
// 消息身份的规范化在 main / renderer 之间必须一致,所以只从 shared 取一份实现。
import { normalizeMessageIdentity } from '../shared/local-query-api'
import { import {
isWindowsVcRuntimeMissingError, isWindowsVcRuntimeMissingError,
WINDOWS_VC_RUNTIME_ERROR_MESSAGE WINDOWS_VC_RUNTIME_ERROR_MESSAGE
} from '../shared/windows-runtime' } from '../shared/windows-runtime'
import { KnowledgeSearchService } from './knowledge/knowledge-search-service' 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 { LocalQueryApiService } from './services/local-query-api-service'
import { AiSearchPipelineService } from './services/ai-search-pipeline-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 { runLegacySafeStorageHelper } from './legacy-safe-storage-helper'
import { runFirstLaunchMigration } from './app-data-migration' import { runFirstLaunchMigration } from './app-data-migration'
import { WechatShareConfigStore } from './wechat-share-config-store' import { WechatShareConfigStore } from './wechat-share-config-store'
@@ -185,6 +202,8 @@ let voiceBatchService: VoiceBatchService | null = null
let knowledgeSearchService: KnowledgeSearchService | null = null let knowledgeSearchService: KnowledgeSearchService | null = null
let localQueryApiService: LocalQueryApiService | null = null let localQueryApiService: LocalQueryApiService | null = null
let aiSearchPipelineService: AiSearchPipelineService | null = null let aiSearchPipelineService: AiSearchPipelineService | null = null
let queryAgentService: QueryAgentService | null = null
let askWechatService: AskWechatService | null = null
let imageDecryptService: ImageDecryptService | null = null let imageDecryptService: ImageDecryptService | null = null
let stickerService: StickerService | null = null let stickerService: StickerService | null = null
let videoAssetService: VideoAssetService | null = null let videoAssetService: VideoAssetService | null = null
@@ -618,6 +637,21 @@ app.whenReady().then(async () => {
aiSearchPipelineService = new AiSearchPipelineService(knowledgeSearchService, aiProviderService) aiSearchPipelineService = new AiSearchPipelineService(knowledgeSearchService, aiProviderService)
localQueryApiService = new LocalQueryApiService(knowledgeSearchService) localQueryApiService = new LocalQueryApiService(knowledgeSearchService)
setLocalQueryApiService(localQueryApiService) 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) => { knowledgeSearchService.onStatusChange((status) => {
for (const window of BrowserWindow.getAllWindows()) { for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('knowledge:status', status) 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<ReturnType<typeof chat.listMessagesAsync>>
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) => { ipcMain.handle('db:getGroupSnapshot', async (_, userMd5: string) => {
const snapshot = await chat.getGroupSnapshotAsync(userMd5) const snapshot = await chat.getGroupSnapshotAsync(userMd5)
if (snapshot && chat.isReady()) { if (snapshot && chat.isReady()) {
@@ -1201,6 +1312,18 @@ app.whenReady().then(async () => {
if (!knowledgeSearchService) throw new Error('本地知识库服务尚未初始化') if (!knowledgeSearchService) throw new Error('本地知识库服务尚未初始化')
return knowledgeSearchService.startCurrentAccountIndex() 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) => { ipcMain.handle('ai-search:run', (event, request: AiSearchPipelineRequest) => {
if (!aiSearchPipelineService) throw new Error('本地搜索服务尚未初始化') if (!aiSearchPipelineService) throw new Error('本地搜索服务尚未初始化')
return aiSearchPipelineService.run(request, (progress) => { return aiSearchPipelineService.run(request, (progress) => {
@@ -1218,6 +1341,27 @@ app.whenReady().then(async () => {
} }
}) })
ipcMain.handle('ai-search:getProviderStatus', () => aiProviderService.getAiSearchProviderStatus()) 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<AskWechatQueryResult> => {
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( ipcMain.handle(
'ai-search:authorizeExternalProvider', 'ai-search:authorizeExternalProvider',
(_, request: AiSearchExternalAuthorizationRequest) => { (_, request: AiSearchExternalAuthorizationRequest) => {
+527 -70
View File
@@ -1,8 +1,11 @@
import { monitorEventLoopDelay } from 'perf_hooks'
import * as chat from '../services/chat-service' import * as chat from '../services/chat-service'
import type { import type {
KnowledgeAttachmentMetadata, KnowledgeAttachmentMetadata,
KnowledgeEvidence, KnowledgeEvidence,
KnowledgeMessageKind, KnowledgeMessageKind,
KnowledgePassProgress,
KnowledgeRuntimeState,
KnowledgeRuntimeStatus, KnowledgeRuntimeStatus,
KnowledgeSearchRequest, KnowledgeSearchRequest,
KnowledgeSearchIpcRequest, KnowledgeSearchIpcRequest,
@@ -32,6 +35,42 @@ const MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH = 700
const MAX_SENDER_ENRICHMENT_SESSIONS = 32 const MAX_SENDER_ENRICHMENT_SESSIONS = 32
const SENDER_ENRICHMENT_SESSION_TTL_MS = 5 * 60 * 1000 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<unknown>
resolve: (value: unknown) => void
reject: (error: unknown) => void
}
type PendingVoiceTranscriptIndex = { type PendingVoiceTranscriptIndex = {
update: VoiceTranscriptUpdate update: VoiceTranscriptUpdate
waiters: Array<{ waiters: Array<{
@@ -43,7 +82,13 @@ type PendingVoiceTranscriptIndex = {
type SenderEnrichmentSession = { type SenderEnrichmentSession = {
lastUsedAt: number lastUsedAt: number
contacts?: Awaited<ReturnType<typeof chat.listContactsAsync>> contacts?: Awaited<ReturnType<typeof chat.listContactsAsync>>
groupSnapshots: Map<string, Awaited<ReturnType<typeof chat.getGroupSnapshotAsync>> | undefined> /**
* conversationId → (wxid → displayName)。
*
* 只缓存"这个群里这些 wxid 解析出来是什么名字",不再缓存整群快照。
* 空串表示「查过、确实没有可用名字」,用于避免同一 session 内重复查询。
*/
groupMemberNames: Map<string, Map<string, string>>
} }
function looksLikeOpaqueSenderId(value: string | undefined): boolean { function looksLikeOpaqueSenderId(value: string | undefined): boolean {
@@ -203,7 +248,12 @@ export class KnowledgeSearchService {
private readonly statusByAccount = new Map<string, KnowledgeRuntimeStatus>() private readonly statusByAccount = new Map<string, KnowledgeRuntimeStatus>()
private readonly statusListeners = new Set<(status: KnowledgeRuntimeStatus) => void>() private readonly statusListeners = new Set<(status: KnowledgeRuntimeStatus) => void>()
private readonly senderEnrichmentSessions = new Map<string, SenderEnrichmentSession>() private readonly senderEnrichmentSessions = new Map<string, SenderEnrichmentSession>()
private wcdbReadTail: Promise<void> = Promise.resolve() /** WCDB 读取的两条通道:交互(查询)优先于后台(索引 pass)。 */
private readonly wcdbPending: PendingWcdbRead[] = []
private wcdbReadBusy = false
private interactiveQueryDepth = 0
private interactiveIdle: Promise<void> = Promise.resolve()
private interactiveIdleResolve: (() => void) | null = null
private wcdbQueueMsTotal = 0 private wcdbQueueMsTotal = 0
private wcdbExecutionMsTotal = 0 private wcdbExecutionMsTotal = 0
private voiceTranscriptResolver: private voiceTranscriptResolver:
@@ -212,6 +262,17 @@ export class KnowledgeSearchService {
private voiceIndexTail: Promise<void> = Promise.resolve() private voiceIndexTail: Promise<void> = Promise.resolve()
private voiceIndexFlushScheduled = false private voiceIndexFlushScheduled = false
private readonly pendingVoiceIndexes = new Map<string, PendingVoiceTranscriptIndex>() private readonly pendingVoiceIndexes = new Map<string, PendingVoiceTranscriptIndex>()
/** 上次由查询触发的追赶同步时间,用于节流(避免每个 Query 都重跑一次索引)。 */
private lastCatchUpRequestedAt = 0
/** 上一遍完整索引 pass 的实际耗时;用于让"是否值得再追一遍"的门槛自我校准。 */
private lastIndexPassMs = 0
/** 当前/最近一次 pass 的真实进度(供 UI 区分"追新"与"补历史")。 */
private passProgress: KnowledgePassProgress | null = null
/** 用户是否已经请求取消当前 pass。取消后主循环在下一个安全点退出。 */
private cancelRequested = false
private lagHistogram: ReturnType<typeof monitorEventLoopDelay> | null = null
private lagMaxMs = 0
private lagStats: { p50: number; p95: number; p99: number; max: number } | null = null
constructor(userDataPath: string, workerPath: string) { constructor(userDataPath: string, workerPath: string) {
this.service = new KnowledgeService(userDataPath, workerPath) this.service = new KnowledgeService(userDataPath, workerPath)
@@ -222,13 +283,32 @@ export class KnowledgeSearchService {
if (!accountId) return this.emptyStatus('') if (!accountId) return this.emptyStatus('')
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId) const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
if (this.indexing.has(accountId)) return current 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 = { const started: KnowledgeRuntimeStatus = {
...current, ...current,
state: current.indexedMessageCount ? 'syncing' : 'building', state: current.indexedMessageCount ? 'syncing' : 'building',
processedMessages: 0, processedMessages: 0,
totalMessages: current.sourceMessageCount, totalMessages: current.sourceMessageCount,
estimatedRemainingMs: null, estimatedRemainingMs: null,
lastError: undefined lastError: undefined,
pass: { ...this.passProgress }
} }
this.publishStatus(started) this.publishStatus(started)
const task = this.indexAccount(accountId) const task = this.indexAccount(accountId)
@@ -243,6 +323,16 @@ export class KnowledgeSearchService {
}) })
.finally(() => { .finally(() => {
this.indexing.delete(accountId) 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) void this.refreshStatus(accountId).catch(() => undefined)
}) })
this.indexing.set(accountId, task) this.indexing.set(accountId, task)
@@ -252,6 +342,26 @@ export class KnowledgeSearchService {
return started 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 * The voice cache remains owned by the voice pipeline. Knowledge only reads
* a current-account snapshot while constructing a derived local index. * a current-account snapshot while constructing a derived local index.
@@ -320,8 +430,10 @@ export class KnowledgeSearchService {
} }
async search(request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> { async search(request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> {
// 源数据最新活跃时间与索引状态无关,先取一次(零额外 WCDB 调用:读的是已缓存的 Session 列表)。
const sourceLatestAt = this.sourceLatestAt()
const accountId = this.currentAccountId() const accountId = this.currentAccountId()
if (!accountId) return this.searchFallback(request, 'unavailable') if (!accountId) return { ...(await this.searchFallback(request, 'unavailable')), sourceLatestAt }
try { try {
const searchRequest: Omit<KnowledgeSearchRequest, 'databaseRoot'> = { const searchRequest: Omit<KnowledgeSearchRequest, 'databaseRoot'> = {
accountId, accountId,
@@ -339,19 +451,81 @@ export class KnowledgeSearchService {
// An existing derived database can answer while its next incremental pass is running. // 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. // Never turn an interactive global search into another full WCDB scan during that pass.
if (result.state === 'ready' || result.evidence.length) { 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)) { if (this.indexing.has(accountId)) {
return { return {
...result, ...result,
source: 'knowledge', source: 'knowledge',
totalMessages: result.indexedMessageCount totalMessages: result.indexedMessageCount,
sourceLatestAt
} }
} }
return this.searchFallback(request, 'unavailable') return { ...(await this.searchFallback(request, 'unavailable')), sourceLatestAt }
} catch (error) { } catch (error) {
console.warn('[Knowledge] search failed, using legacy fallback:', 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<boolean> {
const task = this.indexing.values().next().value as Promise<void> | undefined
if (!task) return true
if (budgetMs <= 0) return false
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<false>((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<void> { private async indexAccount(accountId: string): Promise<void> {
const contacts = await this.listContacts() // 整遍后台索引走 background 通道:交互查询可以插到它前面,不至于被 pass 拖慢。
let processedMessages = 0 const contacts = await this.listContacts('background')
const startedAt = Date.now() 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<string, number>)
// 上一遍记录的源侧边界:被跳过的会话已覆盖到它,不能因为"这一遍没读"而回退。
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.publishStatus({
...(this.statusByAccount.get(accountId) || this.emptyStatus(accountId)), ...(this.statusByAccount.get(accountId) || this.emptyStatus(accountId)),
state: this.statusByAccount.get(accountId)?.indexedMessageCount ? 'syncing' : 'building', state: this.statusByAccount.get(accountId)?.indexedMessageCount ? 'syncing' : 'building',
processedMessages: 0, processedMessages: 0,
totalMessages: null, 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 // WCDB rejects overlapping async pagination. Queue every archive read so
// background indexing and an interactive fallback search can interleave safely. // 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 const sourceMessages = messages
.map((message) => this.toSourceMessage(accountId, contact.md5, message)) .map((message) => this.toSourceMessage(accountId, contact.md5, message))
.filter((message): message is KnowledgeSourceMessage => Boolean(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, accountId,
conversations: [ conversations: [
{ {
conversationId: contact.md5, conversationId: contact.md5,
completeSnapshot: true, // delta 模式下绝不能声明"完整快照":否则 store 会把"不在 delta 里的历史消息"
messages: sourceMessages // 误判为被删除,从而整段重建这个会话,增量就白做了。
completeSnapshot: !isDelta,
messages: sourceMessages,
...(sourceHighWaterTime && sourceHighWaterTime > 0 ? { sourceHighWaterTime } : {})
} }
], ],
chunker: DEFAULT_KNOWLEDGE_CHUNKER, chunker: DEFAULT_KNOWLEDGE_CHUNKER,
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
// 只有「这一遍真的读完了全部会话」(没有任何跳过、没有取消)才写入总量口径,
// 否则会把增量 pass 的部分计数冒充成全量。
//
// 这里数的是真正被建模进索引的源消息,不是"扫到的原始条数";后者(含不可建模的
// 图片/空正文)另走 pass 进度里的 scannedMessages。两个数字回答不同问题。
sourceMessageCount: 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) => { (progress) => {
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId) const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
this.publishStatus({ this.publishStatus({
...current, ...current,
state: current.indexedMessageCount ? 'syncing' : 'building', state: current.indexedMessageCount ? 'syncing' : 'building',
processedMessages: processedMessages + progress.processedMessages, processedMessages: indexedMessages + progress.processedMessages,
totalMessages: null, totalMessages: null,
currentConversationId: progress.conversationId, 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) const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
this.publishStatus({ this.publishStatus({
...current, ...current,
state: current.indexedMessageCount ? 'syncing' : 'building', state: current.indexedMessageCount ? 'syncing' : 'building',
processedMessages, processedMessages: indexedMessages,
totalMessages: null, totalMessages: null,
currentConversationId: contact.md5, 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, { await this.refreshStatus(accountId, {
processedMessages, processedMessages: indexedMessages,
totalMessages: processedMessages, totalMessages: null,
startedAt 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( private async searchFallback(
@@ -502,6 +854,9 @@ export class KnowledgeSearchService {
state: fallbackReason === 'indexing' ? 'indexing' : 'unavailable', state: fallbackReason === 'indexing' ? 'indexing' : 'unavailable',
indexedMessageCount: 0, indexedMessageCount: 0,
indexedChunkCount: 0, indexedChunkCount: 0,
// fallback 是直接扫源数据,不走派生索引,因此没有索引覆盖口径可言。
indexLatestAt: null,
sourceLatestAt: this.sourceLatestAt(),
totalMessages, totalMessages,
timings: { timings: {
...emptyKnowledgeSearchTimings(), ...emptyKnowledgeSearchTimings(),
@@ -639,6 +994,9 @@ export class KnowledgeSearchService {
voiceCoverage.voiceCoverageComplete = voiceCoverage.voiceCoverageComplete =
voiceCoverage.voiceMessageCount === voiceCoverage.transcribedVoiceCount voiceCoverage.voiceMessageCount === voiceCoverage.transcribedVoiceCount
} }
const indexLatestParts = partialResults
.map((result) => result.indexLatestAt)
.filter((value): value is number => typeof value === 'number' && value > 0)
return { return {
state: partialResults.some((result) => result.state === 'ready') state: partialResults.some((result) => result.state === 'ready')
? 'ready' ? 'ready'
@@ -647,6 +1005,8 @@ export class KnowledgeSearchService {
: 'unavailable', : 'unavailable',
indexedMessageCount: Math.max(...partialResults.map((result) => result.indexedMessageCount)), indexedMessageCount: Math.max(...partialResults.map((result) => result.indexedMessageCount)),
indexedChunkCount: Math.max(...partialResults.map((result) => result.indexedChunkCount)), indexedChunkCount: Math.max(...partialResults.map((result) => result.indexedChunkCount)),
// 多个分片取最新的那个:只要有一部分索引更新,整体覆盖口径就按它算。
indexLatestAt: indexLatestParts.length ? Math.max(...indexLatestParts) : null,
evidence: mergedEvidence, evidence: mergedEvidence,
timings, timings,
voiceCoverage voiceCoverage
@@ -680,16 +1040,20 @@ export class KnowledgeSearchService {
} }
} }
private listContacts(): ReturnType<typeof chat.listContactsAsync> { private listContacts(lane: WcdbReadLane = 'interactive'): ReturnType<typeof chat.listContactsAsync> {
return this.enqueueWcdbRead(() => chat.listContactsAsync()) return this.enqueueWcdbRead(() => chat.listContactsAsync(), lane)
} }
private listMessages( private listMessages(
conversationId: string, conversationId: string,
startTime?: number, startTime?: number,
endTime?: number endTime?: number,
lane: WcdbReadLane = 'interactive'
): ReturnType<typeof chat.listMessagesAsync> { ): ReturnType<typeof chat.listMessagesAsync> {
return this.enqueueWcdbRead(() => chat.listMessagesAsync(conversationId, startTime, endTime)) return this.enqueueWcdbRead(
() => chat.listMessagesAsync(conversationId, startTime, endTime),
lane
)
} }
private withVoiceTranscript(message: chat.FormattedMessage): chat.FormattedMessage { private withVoiceTranscript(message: chat.FormattedMessage): chat.FormattedMessage {
@@ -811,22 +1175,36 @@ export class KnowledgeSearchService {
wcdbExecutionMs: this.wcdbExecutionMsTotal - beforeExecutionMs wcdbExecutionMs: this.wcdbExecutionMsTotal - beforeExecutionMs
}, },
source: 'knowledge', 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( private async enrichEvidenceSenders(
evidence: KnowledgeEvidence[], evidence: KnowledgeEvidence[],
retrievalSessionId?: string retrievalSessionId?: string
): Promise<KnowledgeEvidence[]> { ): Promise<KnowledgeEvidence[]> {
const candidateConversationIds = Array.from( // 先按会话聚合需要的 sender,避免"每条 evidence 一次调用"。
new Set( const wxidsByConversation = new Map<string, Set<string>>()
evidence for (const item of evidence) {
.filter((item) => item.senderId && looksLikeOpaqueSenderId(item.sender)) if (!item.senderId || !looksLikeOpaqueSenderId(item.sender)) continue
.map((item) => item.conversationId) let bucket = wxidsByConversation.get(item.conversationId)
) if (!bucket) {
).slice(0, MAX_SENDER_NAME_CONVERSATIONS) bucket = new Set<string>()
if (!candidateConversationIds.length) return evidence wxidsByConversation.set(item.conversationId, bucket)
}
bucket.add(item.senderId)
}
if (!wxidsByConversation.size) return evidence
const session = retrievalSessionId const session = retrievalSessionId
? this.senderEnrichmentSession(retrievalSessionId) ? this.senderEnrichmentSession(retrievalSessionId)
@@ -836,20 +1214,35 @@ export class KnowledgeSearchService {
const groupConversationIds = new Set( const groupConversationIds = new Set(
contacts.filter((contact) => contact.type === 'group').map((contact) => contact.md5) 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<string, Map<string, string>>() const memberNamesByConversation = new Map<string, Map<string, string>>()
for (const conversationId of candidateConversationIds) { for (const conversationId of candidateConversationIds) {
if (!groupConversationIds.has(conversationId)) continue const requested = Array.from(wxidsByConversation.get(conversationId) || [])
let snapshot = session?.groupSnapshots.get(conversationId) if (!requested.length) continue
if (!snapshot) { let memberNames = session?.groupMemberNames.get(conversationId)
snapshot = await this.enqueueWcdbRead(() => chat.getGroupSnapshotAsync(conversationId)) // 只查缓存里还没有的 wxid —— 同一 session 的后续 probe 因此不会重复读 WCDB。
session?.groupSnapshots.set(conversationId, snapshot) 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<string, string>()
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( if (memberNames?.size) memberNamesByConversation.set(conversationId, memberNames)
(snapshot?.members || [])
.map((member) => [member.wxid, groupMemberDisplayName(member)] as const)
.filter(([, name]) => Boolean(name))
)
if (memberNames.size) memberNamesByConversation.set(conversationId, memberNames)
} }
return evidence.map((item) => { return evidence.map((item) => {
@@ -867,7 +1260,7 @@ export class KnowledgeSearchService {
} }
let session = this.senderEnrichmentSessions.get(retrievalSessionId) let session = this.senderEnrichmentSessions.get(retrievalSessionId)
if (!session) { if (!session) {
session = { lastUsedAt: now, groupSnapshots: new Map() } session = { lastUsedAt: now, groupMemberNames: new Map() }
this.senderEnrichmentSessions.set(retrievalSessionId, session) this.senderEnrichmentSessions.set(retrievalSessionId, session)
} }
session.lastUsedAt = now session.lastUsedAt = now
@@ -879,24 +1272,76 @@ export class KnowledgeSearchService {
return session return session
} }
private enqueueWcdbRead<T>(operation: () => Promise<T>): Promise<T> { /**
const enqueuedAt = Date.now() * 交互查询进行中:后台索引会让路。
const run = async (): Promise<T> => { *
const startedAt = Date.now() * 追赶同步会自动遍历上千个会话;如果不让路,一次用户查询会和后台 pass 抢同一个
this.wcdbQueueMsTotal += Math.max(0, startedAt - enqueuedAt) * Worker 与 WCDB 读取通道,被拖到几十秒 —— 查询不能因为索引 backlog 卡住。
try { */
return await operation() beginInteractiveQuery(): void {
} finally { if (this.interactiveQueryDepth === 0) {
this.wcdbExecutionMsTotal += Date.now() - startedAt this.interactiveIdle = new Promise<void>((resolve) => {
} this.interactiveIdleResolve = resolve
})
} }
const result = this.wcdbReadTail.then(run, run) this.interactiveQueryDepth += 1
// Keep the queue usable after a read failure while returning that failure to its caller. }
this.wcdbReadTail = result.then(
() => undefined, endInteractiveQuery(): void {
() => undefined this.interactiveQueryDepth = Math.max(0, this.interactiveQueryDepth - 1)
) if (this.interactiveQueryDepth === 0) {
return result this.interactiveIdleResolve?.()
this.interactiveIdleResolve = null
}
}
/**
* WCDB 异步分页不允许重叠,所有会话读取都必须串行。
*
* 但**后台索引**与**交互查询**不能同权排队:追赶同步会在后台遍历上千个会话,
* 交互读取排在它后面就会被拖成几十秒。因此分两条通道,交互读取优先于尚未开始的后台读取;
* 交互查询最多只等"一个正在执行的读"(WCDB 不允许重叠,这点无法避免)。
*/
private enqueueWcdbRead<T>(operation: () => Promise<T>, lane: WcdbReadLane = 'interactive'): Promise<T> {
const enqueuedAt = Date.now()
return new Promise<T>((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 { private emptyStatus(accountId: string): KnowledgeRuntimeStatus {
@@ -911,7 +1356,10 @@ export class KnowledgeSearchService {
estimatedRemainingMs: null, estimatedRemainingMs: null,
databaseBytes: 0, databaseBytes: 0,
walBytes: 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 remote = await this.service.status({ accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG })
const current = this.statusByAccount.get(accountId) const current = this.statusByAccount.get(accountId)
const indexing = this.indexing.has(accountId) const indexing = this.indexing.has(accountId)
const pass = this.passProgress
const processedMessages = const processedMessages =
progress?.processedMessages ?? current?.processedMessages ?? remote.processedMessages progress?.processedMessages ?? current?.processedMessages ?? remote.processedMessages
const totalMessages = progress?.totalMessages ?? remote.sourceMessageCount const totalMessages = progress?.totalMessages ?? remote.sourceMessageCount
const state = indexing // 一遍 pass 结束后,worker 只知道「派生库能不能查」,不知道这一遍是**被取消**还是**出错**。
// 这两个语义只在这里有(worker 侧的 run_state 已经落库),所以由本地 pass 覆盖,
// 避免取消之后又冒充成一个干净的 ready。
const state: KnowledgeRuntimeState = indexing
? remote.indexedMessageCount > 0 ? remote.indexedMessageCount > 0
? 'syncing' ? 'syncing'
: 'building' : 'building'
: remote.state : pass && (pass.phase === 'cancelled' || pass.phase === 'error')
? pass.phase
: remote.state
const status: KnowledgeRuntimeStatus = { const status: KnowledgeRuntimeStatus = {
...remote, ...remote,
state, state,
processedMessages, processedMessages,
totalMessages, totalMessages,
estimatedRemainingMs: null estimatedRemainingMs: null,
// 派生库自己看不到源数据;这里补上源侧最新活跃时间,UI 才能区分 READY 与 FRESH。
sourceLatestAt: this.sourceLatestAt(),
pass: this.passSnapshot()
} }
this.publishStatus(status) this.publishStatus(status)
return status return status
+10
View File
@@ -48,6 +48,16 @@ export class KnowledgeService {
return this.worker.status({ ...request, databaseRoot: this.databaseRoot }) return this.worker.status({ ...request, databaseRoot: this.databaseRoot })
} }
/** 每个会话已经索引到的源侧时刻(epoch ms);增量 pass 用它跳过没有变化的会话。 */
highWaterMarks(request: Omit<KnowledgeStatusRequest, 'databaseRoot'>): Promise<Record<string, number>> {
return this.worker.highWaterMarks({ ...request, databaseRoot: this.databaseRoot })
}
/** 只中止正在跑的索引任务;查询请求不受影响。 */
cancelIndex(): Promise<boolean> {
return this.worker.cancelActiveIndex()
}
dispose(): Promise<void> { dispose(): Promise<void> {
return this.worker.dispose() return this.worker.dispose()
} }
+183 -16
View File
@@ -191,6 +191,7 @@ export class KnowledgeStore {
async index( async index(
request: Pick<KnowledgeIndexRequest, 'conversations' | 'chunker'> & { request: Pick<KnowledgeIndexRequest, 'conversations' | 'chunker'> & {
sourceMessageCount?: number sourceMessageCount?: number
sourceLatestAt?: number
}, },
signal?: AbortSignal, signal?: AbortSignal,
onProgress?: (progress: KnowledgeIndexProgress) => void onProgress?: (progress: KnowledgeIndexProgress) => void
@@ -242,6 +243,9 @@ export class KnowledgeStore {
this.writeMeta('source_message_count', String(request.sourceMessageCount)) this.writeMeta('source_message_count', String(request.sourceMessageCount))
this.refreshStatsSnapshot() this.refreshStatsSnapshot()
} }
if (request.sourceLatestAt !== undefined) {
this.writeMeta('source_latest_at', String(request.sourceLatestAt))
}
this.setRunState('ready') this.setRunState('ready')
return { return {
accountId: this.accountId, accountId: this.accountId,
@@ -293,23 +297,64 @@ export class KnowledgeStore {
} }
getSearchStatus(): Omit<KnowledgeSearchResult, 'evidence'> { getSearchStatus(): Omit<KnowledgeSearchResult, 'evidence'> {
this.ensureStatsSnapshot() this.ensureStatsSnapshotForQuery()
const indexedMessageCount = this.readStatNumber('stats_message_count') const indexedMessageCount = this.readStatNumber('stats_message_count')
const indexedChunkCount = this.readStatNumber('stats_chunk_count') const indexedChunkCount = this.readStatNumber('stats_chunk_count')
const runState = this.readMeta('run_state') const runState = this.readMeta('run_state')
return { return {
state: // READY 的含义是「这个派生库可以被查询」。一次被中断的 pass 会把 run_state 留在
runState === 'indexing' // 'indexing',但已落盘的分片仍然可用 —— 用它当 state 会让可查询的库看起来不可用。
? 'indexing' // 「正在同步」由 KnowledgeSearchService 依据真实索引任务表达,不靠这里的残留状态。
: runState === 'ready' && indexedChunkCount > 0 state: runState === 'error' ? 'unavailable' : indexedChunkCount > 0 ? 'ready' : 'unavailable',
? 'ready'
: 'unavailable',
indexedMessageCount, indexedMessageCount,
indexedChunkCount, indexedChunkCount,
indexLatestAt: this.readIndexLatestAt(),
timings: emptyKnowledgeSearchTimings() 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 { getRuntimeStatus(): KnowledgeRuntimeStatus {
const search = this.getSearchStatus() const search = this.getSearchStatus()
const storage = this.getStorageStats() const storage = this.getStorageStats()
@@ -320,7 +365,20 @@ export class KnowledgeStore {
const error = this.readMeta('run_error') || undefined const error = this.readMeta('run_error') || undefined
return { return {
accountId: this.accountId, 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, indexedMessageCount: search.indexedMessageCount,
indexedChunkCount: search.indexedChunkCount, indexedChunkCount: search.indexedChunkCount,
sourceMessageCount, sourceMessageCount,
@@ -330,7 +388,10 @@ export class KnowledgeStore {
databaseBytes: storage.databaseBytes, databaseBytes: storage.databaseBytes,
walBytes: storage.walBytes, walBytes: storage.walBytes,
shmBytes: storage.shmBytes, 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() const startedAt = Date.now()
let ftsMs = 0 let ftsMs = 0
let shortTermSearchMs = 0
let messageLoadMs = 0 let messageLoadMs = 0
let chunkExpandMs = 0 let chunkExpandMs = 0
let rankingMs = 0 let rankingMs = 0
@@ -505,6 +567,7 @@ export class KnowledgeStore {
timings: { timings: {
...emptyKnowledgeSearchTimings(), ...emptyKnowledgeSearchTimings(),
ftsMs, ftsMs,
shortTermSearchMs,
messageLoadMs, messageLoadMs,
chunkExpandMs, chunkExpandMs,
rankingMs, rankingMs,
@@ -733,7 +796,9 @@ export class KnowledgeStore {
searchWithStatus(query: KnowledgeQuery): KnowledgeSearchResult { searchWithStatus(query: KnowledgeQuery): KnowledgeSearchResult {
const startedAt = Date.now() const startedAt = Date.now()
const statusStartedAt = Date.now()
const status = this.getSearchStatus() const status = this.getSearchStatus()
const statusMs = Date.now() - statusStartedAt
const measured = status.indexedChunkCount > 0 ? this.searchMeasured(query) : null const measured = status.indexedChunkCount > 0 ? this.searchMeasured(query) : null
const voiceStartedAt = Date.now() const voiceStartedAt = Date.now()
const voiceCoverage = this.getVoiceCoverage(query) const voiceCoverage = this.getVoiceCoverage(query)
@@ -750,6 +815,7 @@ export class KnowledgeStore {
totalMs: workerExecutionMs, totalMs: workerExecutionMs,
globalCountMs: statsRefreshMs, globalCountMs: statsRefreshMs,
voiceCoverageMs, voiceCoverageMs,
statusMs,
workerExecutionMs workerExecutionMs
}, },
conversationRetrieval: measured?.conversationRetrieval, conversationRetrieval: measured?.conversationRetrieval,
@@ -837,6 +903,15 @@ export class KnowledgeStore {
) STRICT; ) STRICT;
CREATE INDEX IF NOT EXISTS knowledge_messages_conversation_time CREATE INDEX IF NOT EXISTS knowledge_messages_conversation_time
ON knowledge_messages (conversation_id, create_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 ( CREATE TABLE IF NOT EXISTS knowledge_chunks (
rowid INTEGER PRIMARY KEY, rowid INTEGER PRIMARY KEY,
chunk_id TEXT NOT NULL UNIQUE, chunk_id TEXT NOT NULL UNIQUE,
@@ -871,6 +946,9 @@ export class KnowledgeStore {
if (!stateColumns.has('complete_snapshot')) { if (!stateColumns.has('complete_snapshot')) {
this.database.exec('ALTER TABLE knowledge_index_state ADD COLUMN complete_snapshot INTEGER NOT NULL DEFAULT 0') 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( const messageColumns = new Set(
asRows(this.database.prepare('PRAGMA table_info(knowledge_messages)').all()).map((row) => asRows(this.database.prepare('PRAGMA table_info(knowledge_messages)').all()).map((row) =>
String(row.name) 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 rebuildStart = Math.max(0, changedAt - chunker.overlapMessages)
const boundaryTime = normalized[rebuildStart]?.createTime ?? 0 const boundaryTime = normalized[rebuildStart]?.createTime ?? 0
@@ -1021,7 +1116,8 @@ export class KnowledgeStore {
highWater, highWater,
normalized.length, normalized.length,
null, null,
conversation.completeSnapshot conversation.completeSnapshot,
conversation.sourceHighWaterTime ?? null
) )
this.database.exec('COMMIT') this.database.exec('COMMIT')
return { chunkCount: chunks.length, updatedChunks: chunks.length } return { chunkCount: chunks.length, updatedChunks: chunks.length }
@@ -1162,14 +1258,20 @@ export class KnowledgeStore {
highWater: number | null, highWater: number | null,
messageCount: number, messageCount: number,
error: string | null = null, error: string | null = null,
completeSnapshot = false completeSnapshot = false,
/**
* 这一遍从 WCDB 读到的**原始**最新 create_time(epoch ms,未经过滤)。
* 与 `highWater`(索引里最后一条可建模消息的时间)不同:图片等不可建模消息会被后者漏掉,
* 于是「最后一条恰好是图片」的会话每次都会被认为是"有新消息"。源侧边界没有这个问题。
*/
sourceHighWater: number | null = null
): void { ): void {
this.database this.database
.prepare( .prepare(
`INSERT INTO knowledge_index_state ( `INSERT INTO knowledge_index_state (
conversation_id, account_id, chunker_version, state, high_water_time, conversation_id, account_id, chunker_version, state, high_water_time,
indexed_message_count, complete_snapshot, last_error, updated_at indexed_message_count, complete_snapshot, last_error, updated_at, source_high_water_time
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(conversation_id) DO UPDATE SET ON CONFLICT(conversation_id) DO UPDATE SET
account_id = excluded.account_id, account_id = excluded.account_id,
chunker_version = excluded.chunker_version, chunker_version = excluded.chunker_version,
@@ -1178,7 +1280,14 @@ export class KnowledgeStore {
indexed_message_count = excluded.indexed_message_count, indexed_message_count = excluded.indexed_message_count,
complete_snapshot = excluded.complete_snapshot, complete_snapshot = excluded.complete_snapshot,
last_error = excluded.last_error, 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( .run(
conversationId, conversationId,
@@ -1189,10 +1298,31 @@ export class KnowledgeStore {
messageCount, messageCount,
completeSnapshot ? 1 : 0, completeSnapshot ? 1 : 0,
error, error,
Date.now() Date.now(),
sourceHighWater
) )
} }
/**
* 每个会话「已经索引到源数据的哪个时刻」(epoch ms)。
*
* 增量 pass 用它判断哪些会话真的需要重新读取:Session 行的 `last_timestamp` 不晚于这个值
* 就说明没有新消息,可以直接跳过(不读 WCDB、不写索引)。
*/
readSourceHighWaterMarks(): Record<string, number> {
const marks: Record<string, number> = {}
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 { private readMeta(key: string): string | null {
const row = this.database.prepare('SELECT value FROM knowledge_meta WHERE key = ?').get(key) as const row = this.database.prepare('SELECT value FROM knowledge_meta WHERE key = ?').get(key) as
| DbRow | DbRow
@@ -1226,6 +1356,43 @@ export class KnowledgeStore {
this.refreshStatsSnapshot() 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 { private markStatsStale(): void {
this.writeMeta('stats_state', 'stale') this.writeMeta('stats_state', 'stale')
} }
+31 -2
View File
@@ -19,6 +19,7 @@ type WorkerResult =
| KnowledgeCapacityPreflight | KnowledgeCapacityPreflight
| KnowledgeSearchResult | KnowledgeSearchResult
| KnowledgeRuntimeStatus | KnowledgeRuntimeStatus
| { marks: Record<string, number> }
| { removed: true } | { removed: true }
type PendingRequest = { type PendingRequest = {
resolve: (result: WorkerResult) => void resolve: (result: WorkerResult) => void
@@ -36,6 +37,14 @@ export class KnowledgeWorkerHost {
private child: ChildProcess | null = null private child: ChildProcess | null = null
private childStartedAt = 0 private childStartedAt = 0
private readonly pending = new Map<string, PendingRequest>() private readonly pending = new Map<string, PendingRequest>()
/**
* 当前在跑的索引请求 id。
*
* 之前没有它,所以「取消同步」在 UI 上不存在、在主进程里也无法表达 ——
* 唯一能停下来的方式就是退出应用。这里显式跟踪,`cancelActiveIndex()` 才能
* 精确地只中止索引,而**不会**影响任何并发进行的查询请求。
*/
private activeIndexRequestId: string | null = null
constructor(private readonly workerPath: string) {} constructor(private readonly workerPath: string) {}
@@ -43,7 +52,9 @@ export class KnowledgeWorkerHost {
payload: KnowledgeIndexRequest, payload: KnowledgeIndexRequest,
onProgress?: (progress: KnowledgeIndexProgress) => void onProgress?: (progress: KnowledgeIndexProgress) => void
): Promise<KnowledgeIndexResult> { ): Promise<KnowledgeIndexResult> {
return this.request('index', payload, onProgress) as Promise<KnowledgeIndexResult> return this.request('index', payload, onProgress, (requestId) => {
this.activeIndexRequestId = requestId
}) as Promise<KnowledgeIndexResult>
} }
preflight(payload: KnowledgeCapacityPreflightRequest): Promise<KnowledgeCapacityPreflight> { preflight(payload: KnowledgeCapacityPreflightRequest): Promise<KnowledgeCapacityPreflight> {
@@ -58,6 +69,21 @@ export class KnowledgeWorkerHost {
return this.request('status', payload) as Promise<KnowledgeRuntimeStatus> return this.request('status', payload) as Promise<KnowledgeRuntimeStatus>
} }
/** 每个会话已经索引到的源侧时刻;用于增量 pass 跳过没有变化的会话。 */
highWaterMarks(payload: KnowledgeStatusRequest): Promise<Record<string, number>> {
return this.request('highWater', payload as unknown as KnowledgeWorkerRequest['payload']).then(
(result) => ('marks' in result ? result.marks : {})
)
}
/** 只中止正在跑的索引任务,返回是否真的有任务被中止。 */
async cancelActiveIndex(): Promise<boolean> {
const target = this.activeIndexRequestId
if (!target) return false
await this.cancel(target)
return true
}
remove(accountId: string, databaseRoot: string): Promise<{ removed: true }> { remove(accountId: string, databaseRoot: string): Promise<{ removed: true }> {
return this.request('remove', { accountId, databaseRoot }) as Promise<{ removed: true }> return this.request('remove', { accountId, databaseRoot }) as Promise<{ removed: true }>
} }
@@ -81,13 +107,15 @@ export class KnowledgeWorkerHost {
private request( private request(
type: KnowledgeWorkerRequest['type'], type: KnowledgeWorkerRequest['type'],
payload: KnowledgeWorkerRequest['payload'], payload: KnowledgeWorkerRequest['payload'],
onProgress?: (progress: KnowledgeIndexProgress) => void onProgress?: (progress: KnowledgeIndexProgress) => void,
onRequestId?: (requestId: string) => void
): Promise<WorkerResult> { ): Promise<WorkerResult> {
const hadWorker = Boolean(this.child?.connected) const hadWorker = Boolean(this.child?.connected)
const child = this.ensureChild() const child = this.ensureChild()
const requestId = randomUUID() const requestId = randomUUID()
const sentAt = Date.now() const sentAt = Date.now()
const request: KnowledgeWorkerRequest = { version: 1, type, requestId, sentAt, payload } const request: KnowledgeWorkerRequest = { version: 1, type, requestId, sentAt, payload }
onRequestId?.(requestId)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.pending.set(requestId, { this.pending.set(requestId, {
resolve, resolve,
@@ -145,6 +173,7 @@ export class KnowledgeWorkerHost {
const pending = this.pending.get(requestId) const pending = this.pending.get(requestId)
if (!pending) return if (!pending) return
this.pending.delete(requestId) this.pending.delete(requestId)
if (this.activeIndexRequestId === requestId) this.activeIndexRequestId = null
if (error) pending.reject(error) if (error) pending.reject(error)
else if (result) pending.resolve(this.applyTransportTimings(result, pending, transport)) else if (result) pending.resolve(this.applyTransportTimings(result, pending, transport))
else pending.reject(new Error('Knowledge worker returned no result')) else pending.reject(new Error('Knowledge worker returned no result'))
+26 -1
View File
@@ -114,6 +114,7 @@ async function handleSearch(
evidence: [], evidence: [],
indexedMessageCount: 0, indexedMessageCount: 0,
indexedChunkCount: 0, indexedChunkCount: 0,
indexLatestAt: null,
timings: emptyKnowledgeSearchTimings() timings: emptyKnowledgeSearchTimings()
}, },
workerReceivedAt, workerReceivedAt,
@@ -153,7 +154,9 @@ async function handleStatus(
estimatedRemainingMs: null, estimatedRemainingMs: null,
databaseBytes: 0, databaseBytes: 0,
walBytes: 0, walBytes: 0,
shmBytes: 0 shmBytes: 0,
indexLatestAt: null,
sourceLatestAt: null
} }
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable }) send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
return return
@@ -166,6 +169,24 @@ async function handleStatus(
}) })
} }
/**
* 每个会话「已经索引到源数据的哪个时刻」。
* 增量 pass 靠它决定哪些会话可以整段跳过(见 `KnowledgeStore.readSourceHighWaterMarks`)。
*/
async function handleHighWater(
request: KnowledgeWorkerRequest,
payload: KnowledgeStatusRequest
): Promise<void> {
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<void> { async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise<void> {
try { try {
if (request.type === 'cancel') { if (request.type === 'cancel') {
@@ -201,6 +222,10 @@ async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number
await handleStatus(request, request.payload as KnowledgeStatusRequest) await handleStatus(request, request.payload as KnowledgeStatusRequest)
return return
} }
if (request.type === 'highWater') {
await handleHighWater(request, request.payload as KnowledgeStatusRequest)
return
}
if (request.type === 'index') { if (request.type === 'index') {
await handleIndex(request, request.payload as KnowledgeIndexRequest) await handleIndex(request, request.payload as KnowledgeIndexRequest)
return return
+86 -2
View File
@@ -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 <name> --args '{...json...}'` → 直接调 Local Query API,
* 用于在没有 GUI 的情况下审计 engine 侧数据(group 解析、覆盖度、Evidence 计数)
*/ */
import type { QueryCorpusScope } from '../shared/local-query-api'
export function parsePocQuestion(argv: readonly string[]): string { export function parsePocQuestion(argv: readonly string[]): string {
const args = [...argv] const args = [...argv]
// npm / pnpm 在复合 script(`build && electron ...`)里会把 `--` 一并追加到命令末尾, // npm / pnpm 在复合 script(`build && electron ...`)里会把 `--` 一并追加到命令末尾,
@@ -11,3 +18,80 @@ export function parsePocQuestion(argv: readonly string[]): string {
if (args[0] === '--') args.shift() if (args[0] === '--') args.shift()
return args.join(' ').trim() return args.join(' ').trim()
} }
export type PocInvocation =
| { kind: 'question'; question: string; scope?: QueryCorpusScope; pretty: boolean }
| { kind: 'tool'; toolName: string; args: Record<string, unknown>; 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 <json>')
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 <json>')
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<string, unknown>), ...(scope ? { scope } : {}) },
pretty
}
}
+43 -5
View File
@@ -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-data-bootstrap'
import { app } from 'electron' import { app } from 'electron'
import { apiTokenStore } from './api-token-store' import { apiTokenStore } from './api-token-store'
import { AIProviderService } from './services/ai-provider-service' import { AIProviderService } from './services/ai-provider-service'
import { QueryAgentPocService, type QueryAgentToolResult } from './services/query-agent-poc-service' import { QueryAgentService, type QueryAgentToolResult } from './services/query-agent-service'
import { parsePocQuestion } from './query-agent-poc-cli' import { parsePocInvocation } from './query-agent-poc-cli'
import { formatProviderDiagnostics, formatTiming } from './query-agent-poc-report' 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 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<string, unknown>): Promise<QueryAgentToolResult> { async function callQueryApi(name: string, input: Record<string, unknown>): Promise<QueryAgentToolResult> {
const paths: Record<string, string> = { const paths: Record<string, string> = {
@@ -26,11 +41,34 @@ async function callQueryApi(name: string, input: Record<string, unknown>): Promi
return { ...payload, status } return { ...payload, status }
} }
/**
* CLI 的 Tool Executor:与生产的进程内 executor 等价 —— 语料边界由 Host 注入请求,
* LLM 无法提供它。两者只差 transport(这里走 HTTP,生产走进程内调用)。
*/
const executePocTool = (
name: string,
input: Record<string, unknown>,
context?: { conversationScope?: unknown }
): Promise<QueryAgentToolResult> =>
callQueryApi(
name,
context?.conversationScope ? { ...input, scope: context.conversationScope } : input
)
async function main(): Promise<void> { async function main(): Promise<void> {
await app.whenReady() 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 provider = new AIProviderService()
const service = new QueryAgentPocService(provider, callQueryApi) const service = new QueryAgentService(provider, executePocTool)
const result = await service.run(question) 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`) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
// 诊断摘要写 stderr,保持 stdout 仍是纯 JSON,方便管道与脚本消费。 // 诊断摘要写 stderr,保持 stdout 仍是纯 JSON,方便管道与脚本消费。
process.stderr.write(formatTiming(result)) process.stderr.write(formatTiming(result))
+3 -3
View File
@@ -1,4 +1,4 @@
import type { QueryAgentPocResult } from './services/query-agent-poc-service' import type { QueryAgentResult } from './services/query-agent-service'
const WIDTH = 24 const WIDTH = 24
@@ -16,7 +16,7 @@ function row(label: string, value: string): string {
* - 3 次以上(无法逐段归属工具耗时时,明确标注为聚合) * - 3 次以上(无法逐段归属工具耗时时,明确标注为聚合)
* - 首次模型调用失败 / 末尾模型调用失败 * - 首次模型调用失败 / 末尾模型调用失败
*/ */
export function formatTiming(result: QueryAgentPocResult): string { export function formatTiming(result: QueryAgentResult): string {
const durations = result.modelDurationsMs || [] const durations = result.modelDurationsMs || []
const toolTotalMs = result.toolTotalMs || 0 const toolTotalMs = result.toolTotalMs || 0
const toolCount = result.toolCallCount || 0 const toolCount = result.toolCallCount || 0
@@ -52,7 +52,7 @@ export function formatTiming(result: QueryAgentPocResult): string {
* 请求级诊断。 * 请求级诊断。
* 只输出 host 与状态字段;绝不输出 API key / Authorization / 完整 URL / 响应正文。 * 只输出 host 与状态字段;绝不输出 API key / Authorization / 完整 URL / 响应正文。
*/ */
export function formatProviderDiagnostics(result: QueryAgentPocResult, host?: string): string { export function formatProviderDiagnostics(result: QueryAgentResult, host?: string): string {
const lines = [ const lines = [
'', '',
'[Provider]', '[Provider]',
+125
View File
@@ -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
}
+75 -371
View File
@@ -15,6 +15,15 @@ import type {
import type { AppSettings } from './settings-store' import type { AppSettings } from './settings-store'
import { generateAgentGroupReport } from './agent-group-report-service' import { generateAgentGroupReport } from './agent-group-report-service'
import { AIProviderService } from './ai-provider-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 { isPackagedRuntime } from '../runtime-mode'
import { import {
getGroupSnapshot, getGroupSnapshot,
@@ -53,30 +62,6 @@ export interface AgentHubNotificationResult {
error?: string 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() const agentAIProvider = new AIProviderService()
function resolveBundledBinary( function resolveBundledBinary(
@@ -127,6 +112,24 @@ export class AgentHubService {
updatedAt: Date.now() 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<boolean> { async start(settings: AppSettings): Promise<boolean> {
void settings void settings
this.stopping = false this.stopping = false
@@ -397,301 +400,82 @@ export class AgentHubService {
.join(' ') .join(' ')
this.addLog('agent-hub', 'info', `收到微信消息 message_id=${messageId || 'unknown'}`) 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()) if (messageId) this.processedMessages.set(messageId, Date.now())
this.addLog( this.addLog(
'agent-hub', 'agent-hub',
'info', 'info',
`匹配群聊总结:${reportIntent.group}(${reportIntent.range})` `匹配群聊总结:${route.intent.group}(${route.intent.range})`
) )
await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch((error) => { await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch((error) => {
this.addLog('agent-hub', 'warn', `等待提示发送失败:${this.errorMessage(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' }) return this.sendHubJson(response, 202, { status: 'generating' })
} }
const groupMemberIntent = this.matchGroupMemberChatIntent(text) if (route.kind === 'group_member_action') {
if (groupMemberIntent) {
if (messageId) this.processedMessages.set(messageId, Date.now()) 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' }) return this.sendHubJson(response, 202, { status: 'generating', mode: 'group-member-summary' })
} }
const contactChatIntent = this.matchContactChatIntent(text) if (route.kind === 'recent_list') {
if (contactChatIntent) { if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' })
if (messageId) this.processedMessages.set(messageId, Date.now()) const items = listRecentChat(route.limit)
if (contactChatIntent.summarize) { const lines = items.map((item, index) => {
void this.summarizeContactChat(inbound, contactChatIntent) const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
return this.sendHubJson(response, 202, { status: 'generating', mode: 'contact-summary' }) 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' }) return this.sendHubJson(response, 200, { status: 'ok' })
} }
const recentChatLimit = this.matchRecentChatIntent(text) if (!text.trim()) {
if (recentChatLimit === null && text.trim()) { this.addLog('agent-hub', 'info', '消息已忽略:内容为空')
if (messageId) this.processedMessages.set(messageId, Date.now()) return this.sendHubJson(response, 202, { status: 'ignored', reason: 'empty text' })
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 (messageId) this.processedMessages.set(messageId, Date.now()) if (messageId) this.processedMessages.set(messageId, Date.now())
this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`) void this.handleKnowledgeQuery(inbound, text)
this.sendHubJson(response, 200, { status: 'ok' }) return this.sendHubJson(response, 202, { status: 'processing', mode: 'query-agent' })
} }
private async handleNaturalLanguage(inbound: InboundMessage, text: string): Promise<void> { /**
try { * 查询类问题("微信里发生了什么"、普通闲聊)统一走 Query Agent Runtime。
const result = await this.resolveNaturalLanguage(text) * 失败时不回退 Report Action,只给用户明确文案。
if (result.reply) { */
await this.sendConnector(inbound, this.formatAIReply(result.reply)) private async handleKnowledgeQuery(inbound: InboundMessage, text: string): Promise<void> {
this.addLog('agent-hub', 'info', '自然语言回复已发送') const service = this.queryAgent
return if (!service) {
} this.addLog('agent-hub', 'error', '查询大脑尚未初始化')
if (!result.command) { await this.sendConnector(inbound, QUERY_AGENT_UNAVAILABLE_TEXT).catch(() => undefined)
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<void> {
if (!isReady()) {
await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。')
return 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<NaturalLanguageResult> {
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 { try {
const json = result.data.match(/\{[\s\S]*\}/)?.[0] const conversationKey = `${String(inbound.account_id || '')}::${String(inbound.from_user_id || '')}`
if (!json) return {} const result = await service.ask(
const parsed = JSON.parse(json) as Record<string, unknown> { requestId: `agent-hub-${Date.now()}-${this.nextLogId}`, text },
const limit = Math.max(1, Math.min(20, Number(parsed['limit']) || 5)) conversationKey
if (parsed['type'] === 'recent') return { command: `最近${limit}条消息` } )
if (parsed['type'] === 'contact' && String(parsed['contact'] || '').trim()) { await this.sendConnector(inbound, this.formatAIReply(queryAgentReplyText(result)))
return { command: `我和${String(parsed['contact']).trim()}最近${limit}条聊了什么` } this.addLog('agent-hub', 'info', `查询回答已发送(${result.status})`)
}
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) } : {}
}
} catch (error) { } catch (error) {
this.addLog('agent-hub', 'warn', `自然语言结果解析失败:${this.errorMessage(error)}`) this.addLog('agent-hub', 'error', `查询处理失败:${this.errorMessage(error)}`)
} await this.sendConnector(inbound, QUERY_AGENT_UNAVAILABLE_TEXT).catch(() => undefined)
return {}
}
private async replyContactChat(
inbound: InboundMessage,
intent: ContactChatIntent
): Promise<void> {
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<void> {
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
)
} }
} }
@@ -881,65 +665,6 @@ export class AgentHubService {
return { ok: response.ok, status: response.status, body: await response.text() } 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<typeof resolveMd5> { private resolveGroup(query: string): ReturnType<typeof resolveMd5> {
const normalize = (value: string): string => const normalize = (value: string): string =>
value 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 { private authorized(header: string | undefined): boolean {
if (!header?.startsWith('Bearer ')) return false if (!header?.startsWith('Bearer ')) return false
const expected = Buffer.from(this.inboundToken) const expected = Buffer.from(this.inboundToken)
@@ -586,6 +586,9 @@ export class AiSearchPipelineService {
state: 'ready', state: 'ready',
indexedMessageCount: 0, indexedMessageCount: 0,
indexedChunkCount: 0, indexedChunkCount: 0,
// Legacy 路径不消费 freshness 口径;显式写 null,避免与 Query Agent 的覆盖语义混淆。
indexLatestAt: null,
sourceLatestAt: null,
totalMessages: 0, totalMessages: 0,
evidence: [], evidence: [],
timings: emptyKnowledgeSearchTimings() timings: emptyKnowledgeSearchTimings()
@@ -1379,6 +1382,8 @@ export class AiSearchPipelineService {
state: 'unavailable', state: 'unavailable',
indexedMessageCount: 0, indexedMessageCount: 0,
indexedChunkCount: 0, indexedChunkCount: 0,
indexLatestAt: null,
sourceLatestAt: null,
totalMessages: 0, totalMessages: 0,
evidence: [], evidence: [],
timings: emptyKnowledgeSearchTimings() timings: emptyKnowledgeSearchTimings()
+276
View File
@@ -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<AiSearchPipelineResult>
export interface AskWechatLogRecord {
level: 'info' | 'warn' | 'error'
message: string
details?: Record<string, unknown>
}
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<AskWechatQueryResult> {
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<AskWechatQueryResult> {
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<Pick<QueryAgentResult, 'totalMs'>>,
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 } : {})
}
}
}
+84
View File
@@ -192,6 +192,61 @@ export function isReady(): boolean {
return dbRef !== null 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<string, number> {
const result = new Map<string, number>()
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[] { export function listContacts(filter?: string): FormattedContact[] {
if (!dbRef) return [] if (!dbRef) return []
@@ -700,6 +755,35 @@ export async function getGroupMemberIdsAsync(
return memberIds ? { roomId, memberIds } : null 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<GroupSnapshot['members']> {
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 { export function isGroupMemberIdsBatchAvailable(): boolean {
return Boolean(dbRef?.getWcdb4Client().isGroupMemberIdsBatchAvailable()) return Boolean(dbRef?.getWcdb4Client().isGroupMemberIdsBatchAvailable())
} }
+579 -53
View File
@@ -2,12 +2,128 @@ import { listContactsAsync, listMessagesAsync, isReady, type FormattedContact, t
import { resolveContact } from './contact-resolution-service' import { resolveContact } from './contact-resolution-service'
import type { KnowledgeSearchService } from '../knowledge/knowledge-search-service' import type { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
import { inferAiSearchTimeRange } from '../../shared/ai-search' 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 LIMIT_MAX = 200
const CONTEXT_MAX = 50 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'] 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<QueryMessageType, string> = {
text: '文本',
image: '图片',
voice: '语音',
video: '视频',
file: '文件',
link: '链接',
sticker: '表情',
system: '系统消息',
other: '消息'
}
function kindOf(message: FormattedMessage): QueryMessageType { function kindOf(message: FormattedMessage): QueryMessageType {
if (message.contentData?.type === 'system') return 'system' if (message.contentData?.type === 'system') return 'system'
if (message.exportMediaType) return message.exportMediaType if (message.exportMediaType) return message.exportMediaType
@@ -21,32 +137,6 @@ function kindOf(message: FormattedMessage): QueryMessageType {
if (message.content?.trim()) return 'text' if (message.content?.trim()) return 'text'
return 'other' 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 contactView(contact: FormattedContact) { return { displayName: contact.m_nsNickName || contact.m_nsUsrName, type: contact.type } as const }
function resolvedTimeRange(input: QueryTimeRange, now = new Date()): ResolvedTimeRange { function resolvedTimeRange(input: QueryTimeRange, now = new Date()): ResolvedTimeRange {
if (input.kind === 'absolute') { 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 } : {}) } 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 { export class LocalQueryApiService {
constructor(private readonly knowledge?: KnowledgeSearchService, private readonly nowProvider: () => Date = () => new Date()) {} constructor(private readonly knowledge?: KnowledgeSearchService, private readonly nowProvider: () => Date = () => new Date()) {}
capabilities(): QueryCapabilitiesResponse { 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 } } } 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) * 解析语料边界。`scope` 由调用方(UI / Host)提供;省略 = 不限。
return { contacts, result } * 这里只做**确定性**展开(群列表 / 会话身份校验),不做任何语义推断。
*/
private async resolveCorpus(scope: QueryCorpusScope | undefined, contacts: FormattedContact[]): Promise<ResolvedCorpus> {
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) { 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 } if (!isReady()) return { status: 'knowledge_unavailable' as const }
const { contacts, result } = await this.resolve(request.target) const contacts = await listContactsAsync()
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 corpus = await this.resolveCorpus(request.scope, contacts)
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()) 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 raw = await listMessagesAsync(contact.md5, range.startTime, range.endTime)
const direction = request.direction || 'any'; const allowed = new Set(request.messageTypes || kinds) 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))) 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) { 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 requestStartedAt = Date.now()
const { contacts, result } = await this.resolve(request.target) if (!request?.timeRange || typeof request.query !== 'string' || !request.query.trim() || (request.variants || []).some((value) => typeof value !== 'string')) return { status: 'invalid_request' as const }
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' })) } // 真实耗时分解:每一段都用 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 } 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 } if (probes.length > 5) return { status: 'invalid_request' as const }
const all = new Map<string, any>(); let coverage: 'complete' | 'partial' | 'unknown' = 'unknown' const contactByMd5 = new Map(contacts.map((contact) => [contact.md5, contact]))
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 }) } const limit = Math.min(LIMIT_MAX, Math.max(1, request.limit || 20))
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) } // 一次查询内多个 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<QuerySearchTimings['knowledge']> | undefined
let found: Awaited<ReturnType<LocalQueryApiService['probe']>>
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<string, FormattedContact>,
retrievalSessionId: string
): Promise<{
evidence: Map<string, QueryEvidenceItem>
indexLatestAt: number | null
sourceLatestAt: number | null
derivedReady: boolean
probeMs: number[]
mergeMs: number
enrichmentMs: number
knowledge?: NonNullable<QuerySearchTimings['knowledge']>
}> {
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<Array<[string, QueryEvidenceItem]>> = []
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<string, QueryEvidenceItem>()
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<string, QueryEvidenceItem> },
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) { async context(request: MessageContextRequest) {
const ref = fromRef(request.messageRef); if (!ref) return { status: 'invalid_request' as const } 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 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) 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) } 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) { async overview(request: ConversationOverviewRequest) {
if (!request?.target?.query?.trim() || !request.timeRange) return { status: 'invalid_request' as const } if (!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 } if (!isReady()) 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 contacts = await listContactsAsync()
const sourceMessageCount = retrieval?.totalMessages || 0 const corpus = await this.resolveCorpus(request.scope, contacts)
const evidence = found.evidence if (corpus.error) return corpus.error as { status: string }
.map((item, index) => ({ messageRef: toRef(item.conversationId, item.messageId), timestamp: item.timestamp, sender: item.sender, sourceKind: item.sourceKind, text: item.text, index })) const targetQuery = request.target?.query?.trim()
.sort((left, right) => left.timestamp - right.timestamp || left.index - right.index) let contact: FormattedContact | undefined
.map(({ index: _index, ...item }) => item) if (targetQuery) {
const sourceState = retrieval?.complete ? 'complete' as const : 'partial' as const const resolved = resolveContact(targetQuery, contacts)
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 (!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))
}
@@ -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<string, unknown>,
context?: QueryAgentToolContext
): Promise<QueryAgentToolResult> => {
// 语料边界由 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}`)
}
}
}
+54
View File
@@ -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 = <T extends { id?: unknown }>(
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 }
}
@@ -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<string, ConversationEntry>()
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)
}
}
@@ -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 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 MAX_TOOL_CALLS = 5
const FORBIDDEN_INPUT_KEYS = new Set(['apiKey', 'authorization', 'token', 'databasePath', 'sql', 'wxid', 'md5']) const FORBIDDEN_INPUT_KEYS = new Set(['apiKey', 'authorization', 'token', 'databasePath', 'sql', 'wxid', 'md5'])
@@ -70,6 +86,8 @@ export interface QueryAgentTraceItem {
status: string status: string
resultCount?: number resultCount?: number
evidenceCount?: number evidenceCount?: number
/** 会话概览覆盖的源消息条数(additive,用于 UI 顶部真实统计)。 */
sourceMessageCount?: number
/** LLM 声明的 temporalBasis。Host 消费它决定 policy,但不会传给 Local Query API。 */ /** LLM 声明的 temporalBasis。Host 消费它决定 policy,但不会传给 Local Query API。 */
temporalBasis?: { kind: QueryTemporalBasisKind; sourceText?: string } temporalBasis?: { kind: QueryTemporalBasisKind; sourceText?: string }
/** Host 自动执行的扩大查询(当前仅 temporalBasis.kind=recall_hint + 有界范围 + 0 结果)。 */ /** Host 自动执行的扩大查询(当前仅 temporalBasis.kind=recall_hint + 有界范围 + 0 结果)。 */
@@ -81,6 +99,13 @@ export interface QueryAgentTraceItem {
resultCount?: number resultCount?: number
evidenceCount?: number evidenceCount?: number
} }
/**
* Engine 侧的真实耗时分解(ADDITIVE 诊断)。
*
* 由 `search_messages` 的 Tool Result 携带,**不会进入模型上下文**(`toolResultForModel`
* 会剥离)。用途:把不透明的 Tool 总耗时拆成 scope / freshness / 每个 probe / 合并 / 证据补全。
*/
searchTimings?: QuerySearchTimings
} }
export interface QueryAgentModelCallDiagnostic { export interface QueryAgentModelCallDiagnostic {
@@ -95,7 +120,24 @@ export interface QueryAgentModelCallDiagnostic {
error?: string 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 question: string
provider: string provider: string
model: string model: string
@@ -112,13 +154,54 @@ export interface QueryAgentPocResult {
traces: QueryAgentTraceItem[] traces: QueryAgentTraceItem[]
answer?: string answer?: string
error?: 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 = ( export type QueryAgentToolExecutor = (
name: string, name: string,
input: Record<string, unknown> input: Record<string, unknown>,
context?: QueryAgentToolContext
) => Promise<QueryAgentToolResult> ) => Promise<QueryAgentToolResult>
/**
* 本次回答实际依据的证据(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。 const SYSTEM_PROMPT = `你是 TraceMemo 的本地聊天查询助手,只能使用提供的四个 Query Tool 获取事实,最终回答只基于 Tool Result。
规划原则: 规划原则:
@@ -131,6 +214,11 @@ const SYSTEM_PROMPT = `你是 TraceMemo 的本地聊天查询助手,只能使
- 普通聊天查询不是 exhaustive investigation。经过合理的检索或可选 context 仍不足以形成强结论时,直接说明证据范围和不确定性,不要循环调用 search、overview、context。 - 普通聊天查询不是 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 混为一谈。 事实边界:不得编造未返回的消息、猜测联系人、修改 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[] { function toolDefinitions(): AIChatToolDefinition[] {
@@ -299,12 +387,10 @@ interface CanonicalTemporalBasis {
} }
/** /**
* LLM Tool Adapter 的 canonicalization: * LLM Tool Adapter 的 canonicalization:剥离 LLM-facing 元数据(temporalBasis)、把 absolute 的
* - temporalBasis 是 LLM-facing 元数据:Host 用它决定 policy,但**剥离**后不传给 Local Query API * ISO-8601 换成 Local Query API 的 epoch seconds、把 `queries[]` 摊平成 query + variants。
* - absolute 的 ISO-8601 → Local Query API 的 epoch seconds
* - search_messages 的 queries[] → Local Query API 的 query + variants
* *
* 这里只做**纯 lexical** 校验(sourceText 是否为用户问题子串 + kind 与 timeRange 是否自洽), * 这里只做**纯 lexical** 校验(sourceText 是否为用户问题子串、kind 与 timeRange 是否自洽),
* 不解释时间短语的意思。 * 不解释时间短语的意思。
*/ */
function canonicalizeToolInput( function canonicalizeToolInput(
@@ -482,10 +568,12 @@ export function validateToolArguments(
return canonicalizeToolInput(name, value as Record<string, unknown>, now, question) return canonicalizeToolInput(name, value as Record<string, unknown>, now, question)
} }
function resultCount(result: QueryAgentToolResult): { resultCount?: number; evidenceCount?: number } { function resultCount(result: QueryAgentToolResult): { resultCount?: number; evidenceCount?: number; sourceMessageCount?: number } {
return { return {
resultCount: typeof result.returnedCount === 'number' ? result.returnedCount : undefined, 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 [] 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<string, QueryAgentEvidenceItem>()
addFromToolResult(toolName: string, result: QueryAgentToolResult): void {
const target = result.target && typeof result.target === 'object' ? (result.target as Record<string, unknown>) : 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<string, unknown>) : 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<string, unknown>
for (const key of Object.keys(item)) {
if (key === 'source') continue
const value = (item as unknown as Record<string, unknown>)[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<string, unknown>
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<string, unknown>)
: 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( constructor(
private readonly provider: QueryAgentProvider, private readonly provider: QueryAgentProvider,
private readonly executeTool: QueryAgentToolExecutor, private readonly executeTool: QueryAgentToolExecutor,
private readonly nowProvider: () => Date = () => new Date() private readonly nowProvider: () => Date = () => new Date()
) {} ) {}
async run(question: string): Promise<QueryAgentPocResult> { async run(question: string, options: QueryAgentRunOptions = {}): Promise<QueryAgentResult> {
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<QueryAgentResult> {
const trimmed = question.trim() const trimmed = question.trim()
const startedAt = Date.now() const startedAt = Date.now()
const runtime = this.provider.getRuntimeConfig() 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: [] } 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: '请输入查询问题', totalMs: Date.now() - startedAt } if (!trimmed) return { ...result, error: '请输入查询问题', errorKind: 'invalid_question', totalMs: Date.now() - startedAt }
if (!runtime.configured) return { ...result, error: '当前 AI Provider 尚未配置', 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<Record<string, unknown>> = [ const messages: Array<Record<string, unknown>> = [
{ role: 'system', content: SYSTEM_PROMPT }, { 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 } { role: 'user', content: trimmed }
] ]
const toolContext: QueryAgentToolContext = options.conversationScope
? { conversationScope: options.conversationScope.scope }
: {}
const evidence = new EvidenceCollector()
let tools = toolDefinitions() let tools = toolDefinitions()
const retry = newRetryState() const retry = newRetryState()
const now = this.nowProvider() const now = this.nowProvider()
let firstModelAt: number | undefined let firstModelAt: number | undefined
let finalModelDuration: number | undefined let finalModelDuration: number | undefined
while (result.toolCallCount < MAX_TOOL_CALLS) { while (result.toolCallCount < MAX_TOOL_CALLS) {
// 真实生命周期边界:还没有任何 Tool 结果 → 这次模型调用是"理解问题";
// 已经有结果 → 这次是在消化证据并**生成回答**。不用定时器、不猜进度。
emit(result.toolCallCount === 0 ? 'understanding' : 'generating_answer')
const modelStartedAt = Date.now() const modelStartedAt = Date.now()
const model = await this.provider.chatWithTools(messages, tools) const model = await this.provider.chatWithTools(messages, tools)
result.modelCallCount += 1 result.modelCallCount += 1
@@ -594,7 +871,7 @@ export class QueryAgentPocService {
...(model.success ? {} : { error: model.error || '模型调用失败' }) ...(model.success ? {} : { error: model.error || '模型调用失败' })
}) })
if (firstModelAt === undefined) firstModelAt = Date.now() 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 || [] const calls = model.toolCalls || []
if (calls.length === 0) { if (calls.length === 0) {
finalModelDuration = modelDuration finalModelDuration = modelDuration
@@ -605,7 +882,7 @@ export class QueryAgentPocService {
return result return result
} }
if (result.toolCallCount + calls.length > MAX_TOOL_CALLS) { 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 } })) }) 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) { for (const call of calls) {
@@ -639,13 +916,17 @@ export class QueryAgentPocService {
toolResult = { status: 'invalid_tool_arguments', field: '$', constraint: 'duplicate_retry', expected: '与上一次实质不同的条件', actual: '与上一次完全相同的条件' } toolResult = { status: 'invalid_tool_arguments', field: '$', constraint: 'duplicate_retry', expected: '与上一次实质不同的条件', actual: '与上一次完全相同的条件' }
} else { } else {
recordAttempt(call.name, traceInput, retry) 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 toolResult = primary
// recall_hint + 有界范围 + 0 结果 → Host 自动做一次“全部历史”corrective lookup。 // recall_hint + 有界范围 + 0 结果 → Host 自动做一次“全部历史”corrective lookup。
// 这是一次本地 Query API 调用:不增加 LLM 往返,也不占用 MAX_TOOL_CALLS。 // 这是一次本地 Query API 调用:不增加 LLM 往返,也不占用 MAX_TOOL_CALLS。
// 注意:自动补查必须沿用同一个语料边界,不能借它逃出当前搜索范围。
if (shouldAutoBroaden(call.name, temporalBasis, traceInput, primary)) { if (shouldAutoBroaden(call.name, temporalBasis, traceInput, primary)) {
const fallbackStartedAt = Date.now() 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) const fallbackCounts = resultCount(fallbackResult)
autoFallback = { reason: AUTO_FALLBACK_REASON, timeRange: { kind: 'all' }, status: fallbackResult.status, durationMs: Date.now() - fallbackStartedAt, ...fallbackCounts } autoFallback = { reason: AUTO_FALLBACK_REASON, timeRange: { kind: 'all' }, status: fallbackResult.status, durationMs: Date.now() - fallbackStartedAt, ...fallbackCounts }
toolResult = { toolResult = {
@@ -672,9 +953,21 @@ export class QueryAgentPocService {
const completedToolResult = toolResult || { status: 'invalid_request', error: '工具调用失败' } const completedToolResult = toolResult || { status: 'invalid_request', error: '工具调用失败' }
const durationMs = Date.now() - inputStartedAt const durationMs = Date.now() - inputStartedAt
result.toolCallCount += 1 result.toolCallCount += 1
counters.toolCallCount = result.toolCallCount
result.toolTotalMs += durationMs 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) 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' const nextTools = completedToolResult.constraint === 'tool_availability'
? tools ? tools
: nextToolDefinitions(call.name, completedToolResult, retry, rangeKind(traceInput) === 'all') : nextToolDefinitions(call.name, completedToolResult, retry, rangeKind(traceInput) === 'all')
@@ -685,6 +978,6 @@ export class QueryAgentPocService {
} }
result.firstModelMs = firstModelAt ? firstModelAt - startedAt : undefined result.firstModelMs = firstModelAt ? firstModelAt - startedAt : undefined
result.totalMs = Date.now() - startedAt result.totalMs = Date.now() - startedAt
return { ...result, error: `超过最大工具调用次数(${MAX_TOOL_CALLS})` } return { ...result, error: `超过最大工具调用次数(${MAX_TOOL_CALLS})`, errorKind: 'tool_limit' }
} }
} }
+8 -1
View File
@@ -42,6 +42,12 @@ export interface AppSettings {
/** Keep a running personal-WeChat OneBot process across app restarts. */ /** Keep a running personal-WeChat OneBot process across app restarts. */
keepPersonalWechatProcess?: boolean keepPersonalWechatProcess?: boolean
windowsWechatPort: string windowsWechatPort: string
/**
* Query Agent 是否为桌面「问问微信」与 Agent Hub 查询类问题的主路径。
* 默认开启;关闭后回退到 Legacy AI Search Pipeline(仅作 runtime regression 时的回退开关,
* 不在用户界面暴露实验性名称)。
*/
queryAgentEnabled: boolean
} }
function getDefaultDbRoot(): string { function getDefaultDbRoot(): string {
@@ -125,7 +131,8 @@ const DEFAULT_SETTINGS: AppSettings = {
ttsSelectedVoiceId: '', ttsSelectedVoiceId: '',
ttsModel: 's2.1-pro-free', ttsModel: 's2.1-pro-free',
keepPersonalWechatProcess: false, keepPersonalWechatProcess: false,
windowsWechatPort: '' windowsWechatPort: '',
queryAgentEnabled: true
} }
const SETTINGS_FILE = path.join( const SETTINGS_FILE = path.join(
+44
View File
@@ -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<Wcdb4GroupMember[]> {
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 { isGroupMemberIdsBatchAvailable(): boolean {
return Boolean(this.wcdbGetGroupMembersBatch) return Boolean(this.wcdbGetGroupMembersBatch)
} }
+20 -1
View File
@@ -1,5 +1,5 @@
import { ElectronAPI } from '@electron-toolkit/preload' import { ElectronAPI } from '@electron-toolkit/preload'
import { Contact, Message } from '../shared/types' import { Contact, Message, MessagesAroundResult } from '../shared/types'
import { import {
GroupReportExportRequest, GroupReportExportRequest,
GroupReportExportResult, GroupReportExportResult,
@@ -114,6 +114,12 @@ import type {
AiSearchPipelineResult, AiSearchPipelineResult,
AiSearchProgressEvent AiSearchProgressEvent
} from '../shared/ai-search' } from '../shared/ai-search'
import type {
AskWechatConfig,
AskWechatQueryRequest,
AskWechatQueryResult,
QueryAgentProgressEvent
} from '../shared/query-agent'
import type { import type {
KnowledgeRuntimeStatus, KnowledgeRuntimeStatus,
KnowledgeSearchIpcRequest, KnowledgeSearchIpcRequest,
@@ -254,6 +260,12 @@ declare global {
endTime?: number, endTime?: number,
options?: { limit?: number } options?: { limit?: number }
) => Promise<Message[]> ) => Promise<Message[]>
getMessagesAround: (
userMd5: string,
messageId: string,
anchorSeconds?: number,
radiusSeconds?: number
) => Promise<MessagesAroundResult>
getGroupSnapshot: (userMd5: string) => Promise<{ getGroupSnapshot: (userMd5: string) => Promise<{
roomId: string roomId: string
memberCount: number memberCount: number
@@ -284,8 +296,15 @@ declare global {
runAiSearch: (request: AiSearchPipelineRequest) => Promise<AiSearchPipelineResult> runAiSearch: (request: AiSearchPipelineRequest) => Promise<AiSearchPipelineResult>
cancelAiSearch: (requestId: string) => Promise<AiSearchCancelResult> cancelAiSearch: (requestId: string) => Promise<AiSearchCancelResult>
onAiSearchProgress: (callback: (progress: AiSearchProgressEvent) => void) => () => void onAiSearchProgress: (callback: (progress: AiSearchProgressEvent) => void) => () => void
getAskWechatConfig: () => Promise<AskWechatConfig>
runAskWechatQuery: (request: AskWechatQueryRequest) => Promise<AskWechatQueryResult>
onAskWechatProgress: (
callback: (requestId: string, event: QueryAgentProgressEvent) => void
) => () => void
forgetAskWechatConversation: () => Promise<void>
getKnowledgeStatus: () => Promise<KnowledgeRuntimeStatus> getKnowledgeStatus: () => Promise<KnowledgeRuntimeStatus>
startKnowledgeIndex: () => Promise<KnowledgeRuntimeStatus> startKnowledgeIndex: () => Promise<KnowledgeRuntimeStatus>
cancelKnowledgeIndex: () => Promise<{ cancellable: boolean; cancelled: boolean }>
onKnowledgeStatus: (callback: (status: KnowledgeRuntimeStatus) => void) => () => void onKnowledgeStatus: (callback: (status: KnowledgeRuntimeStatus) => void) => () => void
aiChat: ( aiChat: (
messages: { role: string; content: string }[], messages: { role: string; content: string }[],
+45
View File
@@ -5,6 +5,7 @@ import type {
GroupReportRenderSnapshotExportRequest GroupReportRenderSnapshotExportRequest
} from '../shared/group-report' } from '../shared/group-report'
import type { ReportTemplateOperationResult } from '../shared/report-template-package' import type { ReportTemplateOperationResult } from '../shared/report-template-package'
import type { MessagesAroundResult } from '../shared/types'
import type { import type {
ReportTemplateCatalogInstallResult, ReportTemplateCatalogInstallResult,
ReportTemplateCatalogResult ReportTemplateCatalogResult
@@ -83,6 +84,12 @@ import type {
AiSearchPipelineResult, AiSearchPipelineResult,
AiSearchProgressEvent AiSearchProgressEvent
} from '../shared/ai-search' } from '../shared/ai-search'
import type {
AskWechatConfig,
AskWechatQueryRequest,
AskWechatQueryResult,
QueryAgentProgressEvent
} from '../shared/query-agent'
import type { import type {
KnowledgeRuntimeStatus, KnowledgeRuntimeStatus,
KnowledgeSearchIpcRequest, KnowledgeSearchIpcRequest,
@@ -137,6 +144,17 @@ const api = {
endTime?: number, endTime?: number,
options?: { limit?: number } options?: { limit?: number }
) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options), ) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options),
/**
* 跳转到证据的锚点读取:按稳定消息 id 在有界时间窗口内精确定位,
* 返回 `found` 让 UI 能诚实降级(而不是假装跳成功)。
*/
getMessagesAround: (
userMd5: string,
messageId: string,
anchorSeconds?: number,
radiusSeconds?: number
): Promise<MessagesAroundResult> =>
ipcRenderer.invoke('db:getMessagesAround', userMd5, messageId, anchorSeconds, radiusSeconds),
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5), getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
getGroupExitMonitorState: (): Promise<GroupExitMonitorState> => getGroupExitMonitorState: (): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:getState'), ipcRenderer.invoke('group-exit-monitor:getState'),
@@ -180,10 +198,37 @@ const api = {
ipcRenderer.on('ai-search:progress', listener) ipcRenderer.on('ai-search:progress', listener)
return () => ipcRenderer.removeListener('ai-search:progress', listener) return () => ipcRenderer.removeListener('ai-search:progress', listener)
}, },
getAskWechatConfig: (): Promise<AskWechatConfig> => ipcRenderer.invoke('ask-wechat:getConfig'),
runAskWechatQuery: (request: AskWechatQueryRequest): Promise<AskWechatQueryResult> =>
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<void> =>
ipcRenderer.invoke('ask-wechat:forgetConversation'),
getKnowledgeStatus: (): Promise<KnowledgeRuntimeStatus> => getKnowledgeStatus: (): Promise<KnowledgeRuntimeStatus> =>
ipcRenderer.invoke('knowledge:getStatus'), ipcRenderer.invoke('knowledge:getStatus'),
startKnowledgeIndex: (): Promise<KnowledgeRuntimeStatus> => startKnowledgeIndex: (): Promise<KnowledgeRuntimeStatus> =>
ipcRenderer.invoke('knowledge:startIndex'), ipcRenderer.invoke('knowledge:startIndex'),
/**
* 取消正在跑的索引 pass。返回 `cancelled: false` 表示请求时已经没有可取消的任务
* (例如刚好自己跑完了)——UI 必须如实反映,而不是无条件显示"已取消"。
*/
cancelKnowledgeIndex: (): Promise<{ cancellable: boolean; cancelled: boolean }> =>
ipcRenderer.invoke('knowledge:cancelIndex'),
onKnowledgeStatus: (callback: (status: KnowledgeRuntimeStatus) => void) => { onKnowledgeStatus: (callback: (status: KnowledgeRuntimeStatus) => void) => {
const listener = (_event: Electron.IpcRendererEvent, status: KnowledgeRuntimeStatus): void => const listener = (_event: Electron.IpcRendererEvent, status: KnowledgeRuntimeStatus): void =>
callback(status) callback(status)
+46 -11
View File
@@ -28,6 +28,8 @@ import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/Dat
import { FirstUseWelcome } from './components/FirstUseWelcome' import { FirstUseWelcome } from './components/FirstUseWelcome'
import { ExportWorkspace } from './components/export/ExportWorkspace' import { ExportWorkspace } from './components/export/ExportWorkspace'
import { AISearchWorkspace } from './components/search/AISearchWorkspace' 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 { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../shared/database-key' import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../shared/database-key'
import { import {
@@ -249,6 +251,13 @@ function App(): React.ReactElement {
const connectionOperationRef = React.useRef(0) const connectionOperationRef = React.useRef(0)
const [activePage, setActivePage] = useState<AppPage>('archive') const [activePage, setActivePage] = useState<AppPage>('archive')
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null) const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
/**
* 精确跳转目标(规范化后的消息 id)。
*
* 与 `archiveJumpTime` 并存而不是替代:时间只能定位到"附近",秒级时间戳在群聊里
* 经常对应多条消息。有 messageRef 时用 id 精确定位,没有时才退回按时间找。
*/
const [archiveJumpMessageId, setArchiveJumpMessageId] = useState<string | null>(null)
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database') const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null) const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result') const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
@@ -1191,6 +1200,7 @@ function App(): React.ReactElement {
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => { const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
setArchiveJumpTime(null) setArchiveJumpTime(null)
setArchiveJumpMessageId(null)
setSelectedContact(contact) setSelectedContact(contact)
selectedContactMd5Ref.current = contact.md5 selectedContactMd5Ref.current = contact.md5
currentGroupSnapshotRef.current = null currentGroupSnapshotRef.current = null
@@ -1265,23 +1275,49 @@ function App(): React.ReactElement {
} }
} }
const handleOpenSearchEvidence = async (contact: Contact, createTime?: number): Promise<void> => { /**
* 跳转到证据的原聊天。
*
* 只靠「真实会话 id + 秒级时间戳」定位不可靠:会话 id 若是展示层合成的 key 就选不中任何
* 真实会话,而时间戳在同一秒有多条消息时会挑错。这里从 `messageRef` 还原真实会话 id →
* 选中会话 → 用 `getMessagesAround` 按稳定消息 id 在有界窗口内精确锚定 → 高亮那一条。
*
* 窗口内找不到时**不静默失败**,明确提示"已打开对应会话,但暂时无法定位原消息"。
*/
const handleOpenSearchEvidence = async (evidence: EvidenceItem): Promise<void> => {
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') setActivePage('archive')
await handleSelectContact(contact) await handleSelectContact(contact)
if (!createTime || selectedContactMd5Ref.current !== contact.md5) return if (selectedContactMd5Ref.current !== contact.md5) return
const anchorSeconds = evidence.message.createTime || undefined
try { try {
const windowStart = Math.max(0, createTime - 12 * 3600) const around = await window.api.getMessagesAround(
const windowEnd = createTime + 12 * 3600 contact.md5,
const nearbyMessages = await window.api.getMessages(contact.md5, windowStart, windowEnd) anchor.messageId,
anchorSeconds
)
if (selectedContactMd5Ref.current !== contact.md5) return if (selectedContactMd5Ref.current !== contact.md5) return
const focusedMessages = sortMessagesChronologically(nearbyMessages) const focusedMessages = sortMessagesChronologically(around.messages)
messageHistoryRef.current = focusedMessages messageHistoryRef.current = focusedMessages
setMessages(applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, focusedMessages))) setMessages(applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, focusedMessages)))
setArchiveJumpTime(createTime) if (around.found) {
setArchiveJumpMessageId(anchor.messageId)
setArchiveJumpTime(anchorSeconds ?? null)
} else {
setReportNotice('已打开对应会话,但暂时无法定位原消息。')
}
} catch (error) { } catch (error) {
console.warn('[Search] evidence context load failed:', error) console.warn('[Search] evidence context load failed:', error)
setReportNotice('证据所在时间段加载失败,请在档案中手动查看') setReportNotice('已打开对应会话,但暂时无法定位原消息。')
} }
} }
@@ -1773,6 +1809,7 @@ function App(): React.ReactElement {
onOpenPersonalWechatSettings={openWechatSendSettings} onOpenPersonalWechatSettings={openWechatSendSettings}
isAiLoading={reportGeneration.isGenerating} isAiLoading={reportGeneration.isGenerating}
jumpToTime={archiveJumpTime} jumpToTime={archiveJumpTime}
jumpToMessageId={archiveJumpMessageId}
/> />
</div> </div>
) )
@@ -2007,9 +2044,7 @@ function App(): React.ReactElement {
dbReady={isDatabaseConnected} dbReady={isDatabaseConnected}
aiModelConfig={aiModelConfig} aiModelConfig={aiModelConfig}
onSelectContact={(contact) => void handleSelectContact(contact)} onSelectContact={(contact) => void handleSelectContact(contact)}
onOpenEvidence={(contact, createTime) => onOpenEvidence={(evidence) => void handleOpenSearchEvidence(evidence)}
void handleOpenSearchEvidence(contact, createTime)
}
onOpenAISettings={openModelSettings} onOpenAISettings={openModelSettings}
onNotice={setReportNotice} onNotice={setReportNotice}
/> />
+18 -8
View File
@@ -24,6 +24,8 @@ interface ChatWindowProps {
onOpenPersonalWechatSettings?: () => void onOpenPersonalWechatSettings?: () => void
isAiLoading?: boolean isAiLoading?: boolean
jumpToTime?: number | null jumpToTime?: number | null
/** 精确跳转目标(消息 id)。与 jumpToTime 取或:任一存在就说明"这是一次跳转"。 */
jumpToMessageId?: string | null
} }
const ChatWindow: React.FC<ChatWindowProps> = ({ const ChatWindow: React.FC<ChatWindowProps> = ({
@@ -41,7 +43,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onOpenTextToSpeechSettings, onOpenTextToSpeechSettings,
onOpenPersonalWechatSettings, onOpenPersonalWechatSettings,
isAiLoading = false, isAiLoading = false,
jumpToTime jumpToTime,
jumpToMessageId
}) => { }) => {
const isGroupChat = Boolean( const isGroupChat = Boolean(
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom') contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
@@ -76,24 +79,30 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
setIsAtLatest(true) setIsAtLatest(true)
}, [contact?.md5]) }, [contact?.md5])
useEffect(() => { // 一次跳转 = 有精确目标或有时间目标。用统一判据,避免"只带了 messageId 但没带时间"
if (jumpToTime !== undefined && jumpToTime !== null) setIsAtLatest(false) // 时自动滚到底把跳转结果顶掉(那会让用户看到"跳过去了但又被弹回最新")。
}, [jumpToTime]) const hasJumpTarget =
(jumpToTime !== undefined && jumpToTime !== null) ||
(jumpToMessageId !== undefined && jumpToMessageId !== null)
useEffect(() => { useEffect(() => {
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return if (hasJumpTarget) setIsAtLatest(false)
}, [hasJumpTarget])
useEffect(() => {
if (!isAtLatest || hasJumpTarget) return
const frame = window.requestAnimationFrame(() => scrollToBottom()) const frame = window.requestAnimationFrame(() => scrollToBottom())
return () => window.cancelAnimationFrame(frame) return () => window.cancelAnimationFrame(frame)
}, [isAtLatest, jumpToTime, messages, scrollToBottom]) }, [isAtLatest, hasJumpTarget, messages, scrollToBottom])
useEffect(() => { useEffect(() => {
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return if (!isAtLatest || hasJumpTarget) return
const content = messageListRef.current?.querySelector('.virtual-message-list') const content = messageListRef.current?.querySelector('.virtual-message-list')
if (!content) return if (!content) return
const observer = new ResizeObserver(() => scrollToBottom()) const observer = new ResizeObserver(() => scrollToBottom())
observer.observe(content) observer.observe(content)
return () => observer.disconnect() return () => observer.disconnect()
}, [contact?.md5, isAtLatest, jumpToTime, scrollToBottom]) }, [contact?.md5, isAtLatest, hasJumpTarget, scrollToBottom])
const openImagePreview = (imageUrl: string): void => { const openImagePreview = (imageUrl: string): void => {
setPreviewImage(imageUrl) setPreviewImage(imageUrl)
@@ -175,6 +184,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onReachTop={onLoadOlderMessages} onReachTop={onLoadOlderMessages}
onImageClick={openImagePreview} onImageClick={openImagePreview}
jumpToTime={jumpToTime} jumpToTime={jumpToTime}
jumpToMessageId={jumpToMessageId}
/> />
<ChatStatusBar <ChatStatusBar
count={filteredMessages.length} count={filteredMessages.length}
@@ -3,6 +3,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import { Contact, Message } from '../../../../shared/types' import { Contact, Message } from '../../../../shared/types'
import { MessageGroup } from './MessageGroup' import { MessageGroup } from './MessageGroup'
import { buildMessageGroups } from './messageGrouping' import { buildMessageGroups } from './messageGrouping'
import { resolveMessageJumpTarget } from './messageJump'
interface MessageListProps { interface MessageListProps {
contact: Contact contact: Contact
@@ -18,6 +19,13 @@ interface MessageListProps {
onReachTop?: () => Promise<void> onReachTop?: () => Promise<void>
onImageClick: (imageUrl: string) => void onImageClick: (imageUrl: string) => void
jumpToTime?: number | null jumpToTime?: number | null
/**
* 精确跳转目标(规范化消息 id,即去掉 `local:` 前缀后的 WCDB 本地 id)。
*
* 优先于 `jumpToTime`:时间只能找到"附近的第一条",秒级时间戳在群聊里经常
* 对应多条消息,于是会定位并高亮错一条。有 id 时必须按 id 找。
*/
jumpToMessageId?: string | null
} }
export function MessageList({ export function MessageList({
@@ -33,7 +41,8 @@ export function MessageList({
onScroll, onScroll,
onReachTop, onReachTop,
onImageClick, onImageClick,
jumpToTime jumpToTime,
jumpToMessageId
}: MessageListProps): React.ReactElement { }: MessageListProps): React.ReactElement {
const groups = React.useMemo(() => buildMessageGroups(messages), [messages]) const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
const groupsRef = React.useRef(groups) const groupsRef = React.useRef(groups)
@@ -47,15 +56,10 @@ export function MessageList({
overscan: 8 overscan: 8
}) })
const virtualItems = virtualizer.getVirtualItems() const virtualItems = virtualizer.getVirtualItems()
const jumpTarget = React.useMemo(() => { const jumpTarget = React.useMemo(
if (jumpToTime === undefined || jumpToTime === null) return null () => resolveMessageJumpTarget(groups, jumpToTime, jumpToMessageId),
const groupIndex = groups.findIndex((group) => [groups, jumpToTime, jumpToMessageId]
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])
React.useEffect(() => { React.useEffect(() => {
if (!jumpTarget) return if (!jumpTarget) return
@@ -70,6 +74,7 @@ export function MessageList({
const scrollElement = event.currentTarget const scrollElement = event.currentTarget
if ( if (
(jumpToTime !== undefined && jumpToTime !== null) || (jumpToTime !== undefined && jumpToTime !== null) ||
(jumpToMessageId !== undefined && jumpToMessageId !== null) ||
scrollElement.scrollTop >= 48 || scrollElement.scrollTop >= 48 ||
loadingOlderRef.current || loadingOlderRef.current ||
isLoadingMessages || isLoadingMessages ||
@@ -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 }
}
@@ -139,13 +139,10 @@ export function AISearchComposer({
</span> </span>
</Button> </Button>
) : ( ) : (
<Button // 同步中**不允许**禁用提问。后台同步是可取消 / 可断点续传的;索引没追平时按
type="submit" // partial + freshness warning 如实作答(覆盖范围由主进程的 coverage/freshness 契约给出)。
className="px-3 text-[11px]" <Button type="submit" className="px-3 text-[11px]">
disabled={knowledgeSyncing} 开始分析
title={knowledgeSyncing ? '知识库同步完成后才能开始分析' : undefined}
>
{knowledgeSyncing ? '同步中,暂不可分析' : '开始分析'}
<span aria-hidden className="text-base leading-3"> <span aria-hidden className="text-base leading-3">
→ →
</span> </span>
@@ -153,7 +150,11 @@ export function AISearchComposer({
)} )}
</div> </div>
<div className="mt-1.5 flex items-center justify-between gap-2 text-[10px] leading-[15px] text-muted-foreground"> <div className="mt-1.5 flex items-center justify-between gap-2 text-[10px] leading-[15px] text-muted-foreground">
<span>Enter 发送 · Shift + Enter 换行</span> <span>
{knowledgeSyncing
? '知识库后台同步中 · 仍可提问,答案会标注覆盖范围'
: 'Enter 发送 · Shift + Enter 换行'}
</span>
<span>AI 仅使用当前搜索所需的受控证据</span> <span>AI 仅使用当前搜索所需的受控证据</span>
</div> </div>
</form> </form>
@@ -1,6 +1,10 @@
import React, { useMemo, useRef, useState } from 'react' import React, { useMemo, useRef, useState } from 'react'
import { aiSearchIntentLabel, aiSearchRangeStart } from '../../../../shared/ai-search' import { aiSearchIntentLabel, aiSearchRangeStart } from '../../../../shared/ai-search'
import type { AiSearchProgressEvent, AiSearchTimeRange } from '../../../../shared/ai-search' import type {
AiSearchPipelineResult,
AiSearchProgressEvent,
AiSearchTimeRange
} from '../../../../shared/ai-search'
import type { import type {
AISearchWorkspaceProps, AISearchWorkspaceProps,
@@ -15,14 +19,23 @@ import {
contactLabel, contactLabel,
formatBytes, formatBytes,
formatDuration, formatDuration,
formatIndexDate,
formatKnowledgeProcessed,
formatMeasuredDuration, formatMeasuredDuration,
formatSearchTraceOverview, formatSearchTraceOverview,
knowledgeIsStale,
knowledgeStateLabel knowledgeStateLabel
} from './searchFormatters' } from './searchFormatters'
import { mapPipelineResultToRendererResult } from './searchMappers' import { mapPipelineResultToRendererResult } from './searchMappers'
import { createSearchResultResetState, resolveSearchResultViewTransition } from './searchState' import { createSearchResultResetState, resolveSearchResultViewTransition } from './searchState'
import { useSearchHistory } from './hooks/useSearchHistory' import { useSearchHistory } from './hooks/useSearchHistory'
import { useKnowledgeStatus } from './hooks/useKnowledgeStatus' import { useKnowledgeStatus } from './hooks/useKnowledgeStatus'
import {
QUERY_AGENT_PROGRESS_STEPS,
queryAgentProgressLabel,
queryAgentProgressStepIndex,
useQueryAgentProgress
} from './hooks/useQueryAgentProgress'
import { useExternalProviderConsent } from './hooks/useExternalProviderConsent' import { useExternalProviderConsent } from './hooks/useExternalProviderConsent'
import { EVIDENCE_PAGE_SIZE, useEvidenceCollection } from './hooks/useEvidenceCollection' import { EVIDENCE_PAGE_SIZE, useEvidenceCollection } from './hooks/useEvidenceCollection'
import { useAiSearchRun } from './hooks/useAiSearchRun' import { useAiSearchRun } from './hooks/useAiSearchRun'
@@ -30,7 +43,18 @@ import { ensureAiSearchDataConsent } from './services/aiSearchProviderConsent'
import { ExternalProviderConsentDialog } from './ExternalProviderConsentDialog' import { ExternalProviderConsentDialog } from './ExternalProviderConsentDialog'
import { AISearchComposer } from './AISearchComposer' import { AISearchComposer } from './AISearchComposer'
import { AISearchEvidencePanel } from './AISearchEvidencePanel' import { AISearchEvidencePanel } from './AISearchEvidencePanel'
import { Button } from '../ui' import {
forgetAskWechatConversation,
requestAskWechatQuery,
resolveQueryAgentEnabled
} from './queryAgentBridge'
import {
askWechatToolLabels,
formatAskWechatStats,
mapAskWechatEvidence
} from './askWechatPresentation'
import type { AskWechatScope, AskWechatStats } from '../../../../shared/query-agent'
import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui'
export function AISearchWorkspace({ export function AISearchWorkspace({
contacts, contacts,
@@ -63,6 +87,12 @@ export function AISearchWorkspace({
const [debugEntries, setDebugEntries] = useState<string[]>([]) const [debugEntries, setDebugEntries] = useState<string[]>([])
const [appLogPath, setAppLogPath] = useState('') const [appLogPath, setAppLogPath] = useState('')
const composerRef = useRef<HTMLTextAreaElement>(null) const composerRef = useRef<HTMLTextAreaElement>(null)
/** Query Agent 主路径的当前请求 id(它不走 useAiSearchRun,需要自己的失效判断)。 */
const askRequestRef = useRef('')
/** Query Agent 是否为当前主路径:控制"搜索范围 / 时间范围"的显隐(主路径隐藏时间范围)。 */
const [queryAgentEnabled, setQueryAgentEnabled] = useState(false)
/** Query Agent 本次回答的真实统计(读取条数 / 证据条数 / 模型调用 / 耗时)。 */
const [askStats, setAskStats] = useState<AskWechatStats | null>(null)
const { const {
evidence, evidence,
setEvidence, setEvidence,
@@ -128,8 +158,15 @@ export function AISearchWorkspace({
syncStarting, syncStarting,
knowledgeSyncing, knowledgeSyncing,
knowledgeSyncingRef, knowledgeSyncingRef,
startKnowledgeSync cancelRequested,
startKnowledgeSync,
cancelKnowledgeSync
} = useKnowledgeStatus({ dbReady, onNotice }) } = useKnowledgeStatus({ dbReady, onNotice })
const {
progress: qaProgress,
begin: beginQueryAgentProgress,
end: endQueryAgentProgress
} = useQueryAgentProgress()
const { const {
externalProviderConsent, externalProviderConsent,
requestExternalProviderConsent, requestExternalProviderConsent,
@@ -155,6 +192,7 @@ export function AISearchWorkspace({
clearEvidenceCollection() clearEvidenceCollection()
setCachedAt(reset.cachedAt) setCachedAt(reset.cachedAt)
setSearchTrace(reset.searchTrace) setSearchTrace(reset.searchTrace)
setAskStats(null)
resetSearchRun() resetSearchRun()
setSearchDetailsOpen(reset.searchDetailsOpen) setSearchDetailsOpen(reset.searchDetailsOpen)
} }
@@ -168,6 +206,17 @@ export function AISearchWorkspace({
) )
}, []) }, [])
// 这个开关只影响"界面暴露哪些边界控件":Query Agent 主路径保留搜索范围、隐藏时间范围。
React.useEffect(() => {
let active = true
void resolveQueryAgentEnabled().then((enabled) => {
if (active) setQueryAgentEnabled(enabled)
})
return () => {
active = false
}
}, [])
const addDebugEntry = (message: string, details: Record<string, unknown> = {}): void => { const addDebugEntry = (message: string, details: Record<string, unknown> = {}): void => {
const entry = `${new Date().toLocaleTimeString('zh-CN')} ${message} ${JSON.stringify(details)}` const entry = `${new Date().toLocaleTimeString('zh-CN')} ${message} ${JSON.stringify(details)}`
setDebugEntries((current) => [entry, ...current].slice(0, 80)) setDebugEntries((current) => [entry, ...current].slice(0, 80))
@@ -184,21 +233,58 @@ export function AISearchWorkspace({
const sourceLabel = { const sourceLabel = {
global: '所有聊天记录', global: '所有聊天记录',
groups: '群聊专属', groups: '群聊专属',
contacts: '联系人专属', contacts: '单聊专属',
conversation: contactLabel(activeContact) conversation: contactLabel(activeContact)
}[scope] }[scope]
/**
* UI 搜索范围 → Query Agent 的 conversationScope(WHERE TO SEARCH)。
*
* 这是确定性数据边界,与时间(WHEN,由问题的 temporalBasis 理解)完全正交;
* 结果会由 Engine 结构性强制:越界 target 会被拒绝,模型无法自行扩大范围。
*/
const currentAskWechatScope = (): AskWechatScope | undefined => {
if (scope === 'global') return { scope: { kind: 'all' }, label: '所有聊天记录' }
if (scope === 'groups') return { scope: { kind: 'groups' }, label: '群聊专属' }
if (scope === 'contacts') {
const contact = allContacts.find(
(item) => item.md5 === (scopeContactMd5 || selectedContact?.md5)
)
if (!contact) return undefined
return {
scope: { kind: 'contact', conversationId: contact.md5 },
label: `单聊专属:${contactLabel(contact)}`
}
}
if (!activeContact) return undefined
return {
scope: { kind: 'current', conversationId: activeContact.md5 },
label: `当前会话:${contactLabel(activeContact)}`
}
}
const currentSyncConversation = knowledgeStatus?.currentConversationId const currentSyncConversation = knowledgeStatus?.currentConversationId
? contactLabel( ? contactLabel(
allContacts.find((contact) => contact.md5 === knowledgeStatus.currentConversationId) allContacts.find((contact) => contact.md5 === knowledgeStatus.currentConversationId)
) )
: '' : ''
/**
* Knowledge 卡片的分区可见性。取值口径与改动前**完全一致**(只认 building / syncing),
* 只是抽成具名变量,避免在 JSX 里重复三元又把 TypeScript 的收窄打断。
*/
const knowledgeIsRunning =
knowledgeStatus?.state === 'building' || knowledgeStatus?.state === 'syncing'
const knowledgeMainLoopLagMs = knowledgeStatus?.pass?.mainLoopLagMs ?? 0
const knowledgeCurrentConversationName =
currentSyncConversation === '未选择会话' ? '正在切换会话' : currentSyncConversation
const modelLabel = aiModelConfig.configured const modelLabel = aiModelConfig.configured
? `${aiModelConfig.providerName} · ${aiModelConfig.modelName}` ? `${aiModelConfig.providerName} · ${aiModelConfig.modelName}`
: '尚未配置 AI 模型' : '尚未配置 AI 模型'
const cancelAnalysis = async (): Promise<void> => { const cancelAnalysis = async (): Promise<void> => {
clearExternalProviderConsent() clearExternalProviderConsent()
const requestId = searchRunRequestId const requestId = searchRunRequestId
if (!requestId) return const askRequestId = askRequestRef.current
if (!requestId && !askRequestId) return
askRequestRef.current = ''
setStage('idle') setStage('idle')
setAnalysisError('') setAnalysisError('')
setSearchDetailsOpen(false) setSearchDetailsOpen(false)
@@ -220,10 +306,9 @@ export function AISearchWorkspace({
): Promise<void> => { ): Promise<void> => {
event?.preventDefault() event?.preventDefault()
if (stage === 'loading') return if (stage === 'loading') return
if (knowledgeSyncingRef.current) { // 索引同步中**不允许**禁止查询:同步是后台的、可取消的、可断点续传的;
onNotice('知识库正在同步,请等待同步完成后再开始分析') // 索引没追平时按 partial + freshness warning 如实作答(覆盖范围由主进程的
return // coverage/freshness 契约给出),绝不把用户挡在门外。
}
const { const {
normalizedQuery, normalizedQuery,
effectiveRange, effectiveRange,
@@ -251,6 +336,43 @@ export function AISearchWorkspace({
setStage('insufficient') setStage('insufficient')
return return
} }
// Legacy AI Search 结果的统一落地(Query Agent 的 Legacy fallback 也走这里,保证展示路径只有一条)。
const applyPipelineResult = (
searchResult: AiSearchPipelineResult,
appliedRange: SearchRange
): void => {
addDebugEntry('主进程搜索任务完成', {
status: searchResult.status,
candidateEvidenceCount: searchResult.candidateEvidenceCount,
finalEvidenceCount: searchResult.evidence.length,
elapsedMs: searchResult.elapsedMs,
errorStage: searchResult.errorStage
})
const mappedResult = mapPipelineResultToRendererResult(searchResult, allContacts)
setSearchTrace(mappedResult.searchTrace)
setEvidenceResult(mappedResult.evidence, mappedResult.evidenceCollection)
setSenderNames(mappedResult.senderNames)
setMessageCount(mappedResult.messageCount)
const viewTransition = resolveSearchResultViewTransition(searchResult, appliedRange)
if (viewTransition.stage !== 'result') {
setAnalysisError(viewTransition.analysisError)
setStage(viewTransition.stage)
return
}
if (!viewTransition.answer) throw new Error('搜索任务未返回回答')
setResultQuery(normalizedQuery)
setAnswer(viewTransition.answer)
rememberQuery(normalizedQuery)
persistSearchResult({
key: cacheKey,
answer: viewTransition.answer,
evidence: mappedResult.evidence,
evidenceCollection: mappedResult.evidenceCollection,
senderNames: mappedResult.senderNames,
messageCount: mappedResult.messageCount
})
setStage('result')
}
try { try {
const cached = consumeCacheBypass() ? null : readCachedResult(cacheKey) const cached = consumeCacheBypass() ? null : readCachedResult(cacheKey)
if (cached) { if (cached) {
@@ -280,12 +402,77 @@ export function AISearchWorkspace({
onNotice('无法确认 AI 服务的数据发送授权,本次检索未执行') onNotice('无法确认 AI 服务的数据发送授权,本次检索未执行')
return return
} }
// 索引正在追新:降级成提示而不是阻断。用户问的是"最近谁聊过 X",
// 拿一份明确标注覆盖范围的 partial 结果,永远好过一句"请等同步完成"。
if (knowledgeSyncingRef.current) { if (knowledgeSyncingRef.current) {
onNotice('知识库正在同步,请等待同步完成后再开始分析') onNotice('知识库正在后台同步,本次结果可能未覆盖最新消息')
return
} }
setStage('loading') setStage('loading')
resetSearchResult() resetSearchResult()
if (await resolveQueryAgentEnabled()) {
// Query Agent 主路径:查询大脑换成 QueryAgentService(与微信 Agent Hub 同一实现)。
// Legacy 只在 Runtime 不可恢复错误时由主进程回退,并以 engine='legacy' 返回 legacy 结果。
setQueryAgentEnabled(true)
askRequestRef.current = requestId
// 订阅**本次** requestId 的真实进度;`finally` 里一定退订,
// 否则下一次查询会被上一次的残留阶段污染成"假进度"。
beginQueryAgentProgress(requestId)
const askResult = await requestAskWechatQuery({
requestId,
text: normalizedQuery,
// 搜索范围是 UI 决定的数据边界;时间不在 UI 上(写进问题,由 temporalBasis 理解)。
scope: currentAskWechatScope(),
legacy: {
scope,
range: effectiveRange,
conversationId,
timeRangeOverride: effectiveTimeRangeOverride
}
}).finally(() => endQueryAgentProgress())
// 桥缺失(旧 preload / 测试环境)时不算 Query Agent 失败,直接走下面的 Legacy 路径。
if (!askResult) {
askRequestRef.current = ''
} else if (askRequestRef.current !== requestId) {
return
} else {
askRequestRef.current = ''
if (askResult.engine === 'legacy') {
applyPipelineResult(askResult.result, effectiveRange)
return
}
if (askResult.status === 'answered') {
const mappedEvidence = mapAskWechatEvidence(askResult.evidence)
addDebugEntry('查询 Agent 完成', { ...askResult.diagnostics })
setResultQuery(normalizedQuery)
setAnswer(askResult.answer)
// 真实证据直接来自 Runtime 收集的 Tool 结果,不从回答文本反解析。
setEvidenceResult(mappedEvidence, mappedEvidence)
setAskStats(askResult.stats)
setMessageCount(0)
rememberQuery(normalizedQuery)
persistSearchResult({
key: cacheKey,
answer: askResult.answer,
evidence: mappedEvidence,
evidenceCollection: mappedEvidence,
senderNames: {},
messageCount: 0
})
setStage('result')
return
}
// Provider 不可用 / Runtime 失败且无法回退:给出明确文案,不静默回退成另一次搜索。
const failureMessage =
askResult.status === 'provider_unavailable' || askResult.status === 'error'
? askResult.message
: '本次查询没有完成,请稍后再试。'
setAskStats(null)
addDebugEntry('查询失败', { ...askResult.diagnostics })
setAnalysisError(failureMessage)
setStage('insufficient')
return
}
}
const outcome = await startSearch({ const outcome = await startSearch({
requestId, requestId,
text: normalizedQuery, text: normalizedQuery,
@@ -306,38 +493,7 @@ export function AISearchWorkspace({
setStage('insufficient') setStage('insufficient')
return return
} }
const searchResult = outcome.result applyPipelineResult(outcome.result, effectiveRange)
addDebugEntry('主进程搜索任务完成', {
status: searchResult.status,
candidateEvidenceCount: searchResult.candidateEvidenceCount,
finalEvidenceCount: searchResult.evidence.length,
elapsedMs: searchResult.elapsedMs,
errorStage: searchResult.errorStage
})
const mappedResult = mapPipelineResultToRendererResult(searchResult, allContacts)
setSearchTrace(mappedResult.searchTrace)
setEvidenceResult(mappedResult.evidence, mappedResult.evidenceCollection)
setSenderNames(mappedResult.senderNames)
setMessageCount(mappedResult.messageCount)
const viewTransition = resolveSearchResultViewTransition(searchResult, effectiveRange)
if (viewTransition.stage !== 'result') {
setAnalysisError(viewTransition.analysisError)
setStage(viewTransition.stage)
return
}
if (!viewTransition.answer) throw new Error('搜索任务未返回回答')
setResultQuery(normalizedQuery)
setAnswer(viewTransition.answer)
rememberQuery(normalizedQuery)
persistSearchResult({
key: cacheKey,
answer: viewTransition.answer,
evidence: mappedResult.evidence,
evidenceCollection: mappedResult.evidenceCollection,
senderNames: mappedResult.senderNames,
messageCount: mappedResult.messageCount
})
setStage('result')
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : '读取聊天记录失败' const errorMessage = error instanceof Error ? error.message : '读取聊天记录失败'
addDebugEntry('检索失败', { error: errorMessage }) addDebugEntry('检索失败', { error: errorMessage })
@@ -354,6 +510,9 @@ export function AISearchWorkspace({
const startNewQuestion = (): void => { const startNewQuestion = (): void => {
clearCacheBypass() clearCacheBypass()
askRequestRef.current = ''
// 新问题 = 新的对话上下文:清掉 Query Agent 的澄清记忆,避免和上一次追问串味。
forgetAskWechatConversation()
setQuery('') setQuery('')
setResultQuery('') setResultQuery('')
setStage('idle') setStage('idle')
@@ -413,6 +572,45 @@ export function AISearchWorkspace({
: progress : progress
? '◉' ? '◉'
: '○' : '○'
// Query Agent 主路径:不产生 Legacy 的进度事件,也不固定说"从知识库检索"
// (query_messages 直读微信数据库)。
// 阶段与文案由**真实 Runtime 生命周期**驱动,不是静态 4 步:
// 用户能看到"现在卡在哪一步、已经过去多久"。
if (queryAgentEnabled) {
const currentStep = queryAgentProgressStepIndex(qaProgress)
const wideScope = scope === 'global' || scope === 'groups'
return (
<div className="ai-search-loading">
<span className="ai-search-kicker">正在查询本地聊天记录</span>
<h2>{queryAgentProgressLabel(qaProgress, wideScope)}</h2>
<p>
搜索范围:{sourceLabel}
{qaProgress ? ` · 已用时 ${formatDuration(qaProgress.elapsedMs)}` : ''}
</p>
<div className="ai-search-pipeline" aria-label="本次查询过程">
{QUERY_AGENT_PROGRESS_STEPS.map((step, index) => {
const state = index < currentStep ? 'done' : index === currentStep ? 'active' : ''
return (
<section key={step.stage} className={`ai-search-pipeline-step ${state}`}>
<span className="ai-search-pipeline-mark">
{index < currentStep ? '✓' : index === currentStep ? '◉' : '○'}
</span>
<div>
<strong>{step.label}</strong>
{/* 当前步的副提示:Tool 阶段说明"在搜什么范围",模型阶段说明在做什么。 */}
{index === currentStep && (
<span className="block text-[10px] text-muted-foreground">
{queryAgentProgressLabel(qaProgress, wideScope)}
</span>
)}
</div>
</section>
)
})}
</div>
</div>
)
}
return ( return (
<div className="ai-search-loading"> <div className="ai-search-loading">
<span className="ai-search-kicker">本地检索进行中</span> <span className="ai-search-kicker">本地检索进行中</span>
@@ -674,27 +872,69 @@ export function AISearchWorkspace({
<div> <div>
<span className="ai-search-kicker">✓ 已完成</span> <span className="ai-search-kicker">✓ 已完成</span>
<h2>{resultQuery || query}</h2> <h2>{resultQuery || query}</h2>
<p> {askStats ? (
知识库已收录 {messageCount.toLocaleString()} 条消息 →{' '} <>
{cachedAt <p>已生成回答</p>
? `缓存中保留 ${evidenceCollection.length} 条 Evidence` <div className="ai-search-trace" aria-label="本次查询真实统计">
: searchTrace?.retrievedEvidence !== undefined {formatAskWechatStats(askStats).map((chip) => (
? `读取 ${searchTrace.retrievedEvidence} 条范围消息` <span key={chip}>{chip}</span>
: `读取 ${evidence.length} 条消息`}{' '}→ {evidence.length} 条 Evidence → ))}
已生成回答{cachedAt ? ' · 已使用缓存' : ''} {askWechatToolLabels(askStats.tools).map((label) => (
</p> <span key={label}>能力:{label}</span>
{searchTrace && ))}
(() => { </div>
const overview = formatSearchTraceOverview(searchTrace) {/* 耗时拆解:把总耗时还原成"AI 花了多少 / 本地查询花了多少"。
return ( 普通 UI 只出现这三个用户能理解的名字,不出现 firstModelMs / toolTotalMs
<div className="ai-search-trace" aria-label="本次检索追踪"> 这类工程字段;逐次调用的细节只在 dev 模式展开,供排障用。 */}
<span>总耗时 {overview.totalDuration}</span> {askStats.timings && (
<span>本地检索 {overview.knowledgeDuration}</span> <div className="ai-search-trace" aria-label="本次查询耗时拆解">
<span>AI {overview.aiDuration}</span> <span data-testid="query-timing-total">
<span>上下文 {overview.contextEvidence}</span> 总耗时 {formatDuration(askStats.timings.totalMs)}
</span>
<span data-testid="query-timing-model">
AI {formatDuration(askStats.timings.modelMs)}
</span>
<span data-testid="query-timing-local">
本地查询 {formatDuration(askStats.timings.localQueryMs)}
</span>
{import.meta.env.DEV && (
<span data-testid="query-timing-dev-detail">
dev 逐次调用:AI [
{askStats.timings.modelDurationsMs?.map((v) => Math.round(v)).join(', ') ||
'-'}
] ms · 本地 [
{askStats.timings.toolDurationsMs?.map((v) => Math.round(v)).join(', ') || '-'}
] ms
</span>
)}
</div> </div>
) )}
})()} </>
) : (
<>
<p>
知识库已收录 {messageCount.toLocaleString()} 条消息 →{' '}
{cachedAt
? `缓存中保留 ${evidenceCollection.length} 条 Evidence`
: searchTrace?.retrievedEvidence !== undefined
? `读取 ${searchTrace.retrievedEvidence} 条范围消息`
: `读取 ${evidence.length} 条消息`}{' '}
→ {evidence.length} 条 Evidence → 已生成回答{cachedAt ? ' · 已使用缓存' : ''}
</p>
{searchTrace &&
(() => {
const overview = formatSearchTraceOverview(searchTrace)
return (
<div className="ai-search-trace" aria-label="本次检索追踪">
<span>总耗时 {overview.totalDuration}</span>
<span>本地检索 {overview.knowledgeDuration}</span>
<span>AI {overview.aiDuration}</span>
<span>上下文 {overview.contextEvidence}</span>
</div>
)
})()}
</>
)}
{renderSearchDetails()} {renderSearchDetails()}
</div> </div>
<div className="ai-search-result-actions"> <div className="ai-search-result-actions">
@@ -902,89 +1142,169 @@ export function AISearchWorkspace({
当前会话{activeContact ? ` · ${contactLabel(activeContact)}` : ''} 当前会话{activeContact ? ` · ${contactLabel(activeContact)}` : ''}
</button> </button>
</div> </div>
{/* 单聊专属需要一个明确的联系人(Legacy 下同样由档案选择决定,这里在 Query Agent 主路径提供显式选择) */}
{queryAgentEnabled && scope === 'contacts' && (
<Select
value={scopeContactMd5 || selectedContact?.md5 || ''}
onValueChange={(value) => setScopeContactMd5(value)}
>
<SelectTrigger aria-label="选择单聊联系人" className="mt-2 w-full">
<SelectValue placeholder="选择联系人" />
</SelectTrigger>
<SelectContent>
{allContacts
.filter((contact) => contact.type !== 'group')
.map((contact) => (
<SelectItem key={contact.md5} value={contact.md5}>
{contactLabel(contact)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</section> </section>
<section className="ai-search-filter-section ai-search-time-section"> {queryAgentEnabled ? (
<span className="ai-search-field-label">时间范围</span> <section className="ai-search-filter-section">
<div className="ai-search-time-menu"> <span className="ai-search-field-label">时间</span>
{(Object.keys(RANGE_LABELS) as SearchRange[]).map((item) => ( <p className="mt-1 text-[11px] leading-[17px] text-muted-foreground">
<button 时间直接写在问题里,例如「上个月 BOBO 发过什么文件?」「最近 7 天群里聊了什么?」
key={item} </p>
type="button" </section>
className={range === item ? 'active' : ''} ) : (
aria-pressed={range === item} <section className="ai-search-filter-section ai-search-time-section">
onClick={() => { <span className="ai-search-field-label">时间范围</span>
setRange(item) <div className="ai-search-time-menu">
setTimeRangeOverride({ {(Object.keys(RANGE_LABELS) as SearchRange[]).map((item) => (
startTime: aiSearchRangeStart(item), <button
endTime: undefined, key={item}
label: RANGE_LABELS[item], type="button"
reason: '用户在界面选择的时间范围', className={range === item ? 'active' : ''}
source: 'user_selected' aria-pressed={range === item}
}) onClick={() => {
}} setRange(item)
> setTimeRangeOverride({
<span aria-hidden>{item === 'all' ? '▣' : item === 'today' ? '▤' : '◷'}</span> startTime: aiSearchRangeStart(item),
{item === 'all' ? '不限时间' : RANGE_LABELS[item]} endTime: undefined,
</button> label: RANGE_LABELS[item],
))} reason: '用户在界面选择的时间范围',
</div> source: 'user_selected'
</section> })
}}
>
<span aria-hidden>{item === 'all' ? '▣' : item === 'today' ? '▤' : '◷'}</span>
{item === 'all' ? '不限时间' : RANGE_LABELS[item]}
</button>
))}
</div>
</section>
)}
<section <section
className={`ai-search-knowledge-card ${knowledgeStatus?.state || 'unavailable'}`} className={`ai-search-knowledge-card ${knowledgeStatus?.state || 'unavailable'}`}
aria-label="知识库同步状态" aria-label="知识库同步状态"
> >
<div className="ai-search-knowledge-card-heading"> {/* 卡片自上而下固定五段:HEADER → CURRENT PASS → DATABASE STATUS → CURRENT → ACTION。
<div> 每段的「标签 / 数值」行都用同一套栅格(label 可收缩、value 取自然宽且不折断),
<span>KNOWLEDGE BASE</span> 侧栏只有 ~145px 可用宽度,靠栅格而不是靠缩字号来避免挤成一团。 */}
<strong>{knowledgeStateLabel(knowledgeStatus)}</strong> <div className="ai-search-knowledge-heading">
<div className="ai-search-knowledge-heading-text">
<span className="ai-search-knowledge-kicker">KNOWLEDGE BASE</span>
{/* 折行规则在 CSS 里(word-break: keep-all):中文不在字与字之间断开,
「 · 」两侧的空格仍是断点,所以折成两行时只会断在「可用 · 」之后。 */}
<strong className="ai-search-knowledge-state" data-testid="knowledge-state-label">
{knowledgeStateLabel(knowledgeStatus)}
</strong>
</div> </div>
<span className="ai-search-knowledge-dot" aria-hidden /> <span className="ai-search-knowledge-dot" aria-hidden />
</div> </div>
<p className="ai-search-knowledge-description"> <p className="ai-search-knowledge-description">
{knowledgeStatus?.state === 'unavailable' {knowledgeStatus?.state === 'unavailable'
? '知识库不会自动建立,只有点击下方按钮后才会在后台同步。' ? '知识库不会自动建立,只有点击下方按钮后才会在后台同步。'
: '后台增量同步不会影响原始微信聊天记录。'} : knowledgeStatus?.state === 'cancelled'
? '同步已取消。已经建立的索引仍然可用,下次同步会从中断处继续,不会从头重扫。'
: knowledgeIsStale(knowledgeStatus) && knowledgeStatus?.indexLatestAt
? `索引还没追上最新聊天:跨会话搜索目前只覆盖到 ${formatIndexDate(knowledgeStatus.indexLatestAt)},之后的记录需要同步后才可检索。不影响你现在提问,但答案会标注覆盖范围。`
: '后台增量同步不会影响原始微信聊天记录,也不会阻塞提问。'}
</p> </p>
{(knowledgeStatus?.state === 'building' || knowledgeStatus?.state === 'syncing') && ( {/* CURRENT PASS:只有这一遍真的在跑时才出现。 */}
<div className="ai-search-sync-progress"> {knowledgeIsRunning && (
<div className="ai-search-sync-progress-top"> <div className="ai-search-knowledge-pass">
<span> <div className="ai-search-knowledge-rows">
已处理 {knowledgeStatus.processedMessages.toLocaleString()} 条 <div className="ai-search-knowledge-row">
{knowledgeStatus.totalMessages <span className="ai-search-knowledge-label">会话进度</span>
? ` / ${knowledgeStatus.totalMessages.toLocaleString()}` <strong className="ai-search-knowledge-value">
: ''} {knowledgeStatus.pass
</span> ? `${knowledgeStatus.pass.processedConversations.toLocaleString()} / ${knowledgeStatus.pass.totalConversations.toLocaleString()}`
<span> : '准备中'}
{knowledgeStatus.totalMessages </strong>
? `${Math.min(100, Math.round((knowledgeStatus.processedMessages / knowledgeStatus.totalMessages) * 100))}%` </div>
: '统计中'}
</span>
</div> </div>
<div className="ai-search-sync-progress-track"> <div className="ai-search-sync-progress-track">
<span <span
style={{ style={{
width: knowledgeStatus.totalMessages // 有真实分母时用真实比例;没有分母时不再假装 35% 的"假进度条",
? `${Math.min(100, (knowledgeStatus.processedMessages / knowledgeStatus.totalMessages) * 100)}%` // 改成一条不确定态(UI 上用动画表示"在跑")。
: '35%' width: knowledgeStatus.pass?.totalConversations
? `${Math.min(
100,
(knowledgeStatus.pass.processedConversations /
knowledgeStatus.pass.totalConversations) *
100
)}%`
: '100%',
...(knowledgeStatus.pass?.totalConversations
? {}
: { animation: 'ai-search-indeterminate 1.4s ease-in-out infinite' })
}} }}
/> />
</div> </div>
{/* 「已处理 X 条 / 统计中」是**无分母**的伪进度。这里改成两个真实数字:
新增索引(真的写进去的)与已扫描(读了多少),口径写清楚。
这一句太长,允许在「 · 」处折成两行(同上,交给 CSS 处理)。 */}
<p className="ai-search-knowledge-pass-line" data-testid="knowledge-pass-progress">
{formatKnowledgeProcessed(knowledgeStatus)}
</p>
{knowledgeStatus.pass && (
<p className="ai-search-knowledge-pass-line" data-testid="knowledge-pass-scope">
{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()} 个会话`}
</p>
)}
{knowledgeStatus.pass && knowledgeStatus.pass.skippedConversations > 0 && (
<p className="ai-search-knowledge-pass-line">
已跳过 {knowledgeStatus.pass.skippedConversations.toLocaleString()} 个没有新消息的会话
</p>
)}
</div> </div>
)} )}
<div className="ai-search-knowledge-details"> <div className="ai-search-knowledge-rows">
<div> <div className="ai-search-knowledge-row">
<span>已索引消息</span> <span className="ai-search-knowledge-label">已索引消息</span>
<strong>{(knowledgeStatus?.indexedMessageCount || 0).toLocaleString()}</strong> <strong className="ai-search-knowledge-value">
{(knowledgeStatus?.indexedMessageCount || 0).toLocaleString()}
</strong>
</div> </div>
<div> <div className="ai-search-knowledge-row">
<span>知识片段</span> <span className="ai-search-knowledge-label">知识片段</span>
<strong>{(knowledgeStatus?.indexedChunkCount || 0).toLocaleString()}</strong> <strong className="ai-search-knowledge-value">
{(knowledgeStatus?.indexedChunkCount || 0).toLocaleString()}
</strong>
</div> </div>
<div> {knowledgeStatus?.indexLatestAt ? (
<span>磁盘占用</span> <div className="ai-search-knowledge-row">
<strong> <span className="ai-search-knowledge-label">最新索引</span>
<strong className="ai-search-knowledge-value">
{formatIndexDate(knowledgeStatus.indexLatestAt)}
</strong>
</div>
) : null}
<div className="ai-search-knowledge-row">
<span className="ai-search-knowledge-label">磁盘占用</span>
<strong className="ai-search-knowledge-value">
{formatBytes( {formatBytes(
(knowledgeStatus?.databaseBytes || 0) + (knowledgeStatus?.databaseBytes || 0) +
(knowledgeStatus?.walBytes || 0) + (knowledgeStatus?.walBytes || 0) +
@@ -992,41 +1312,70 @@ export function AISearchWorkspace({
)} )}
</strong> </strong>
</div> </div>
{knowledgeStatus?.currentConversationId && </div>
(knowledgeStatus.state === 'building' || knowledgeStatus.state === 'syncing') && ( {(knowledgeStatus?.currentConversationId && knowledgeIsRunning) ||
<div> (knowledgeStatus?.pass && knowledgeStatus.pass.mainLoopLagMs > 0) ? (
<span>当前会话</span> <div className="ai-search-knowledge-rows">
<strong> {knowledgeStatus?.currentConversationId && knowledgeIsRunning && (
{currentSyncConversation === '未选择会话' <div className="ai-search-knowledge-row">
? '正在切换会话' <span className="ai-search-knowledge-label">当前会话</span>
: currentSyncConversation} {/* 会话名可以很长(群名 / 备注),这里必须省略而不是撑破侧栏。 */}
<strong
className="ai-search-knowledge-value ai-search-knowledge-value--truncate"
title={knowledgeCurrentConversationName}
>
{knowledgeCurrentConversationName}
</strong> </strong>
</div> </div>
)} )}
</div> {knowledgeMainLoopLagMs > 0 && (
<div className="ai-search-knowledge-row">
<span className="ai-search-knowledge-label">界面卡顿峰值</span>
<strong className="ai-search-knowledge-value">
{formatDuration(knowledgeMainLoopLagMs)}
</strong>
</div>
)}
</div>
) : null}
{knowledgeStatus?.state === 'error' && ( {knowledgeStatus?.state === 'error' && (
<p className="ai-search-knowledge-error"> <p className="ai-search-knowledge-error">
{knowledgeStatus.lastError || '同步异常,旧搜索仍可使用。'} {knowledgeStatus.lastError || '同步异常,已建立的索引仍可使用。'}
</p> </p>
)} )}
<Button {knowledgeStatus?.state === 'cancelled' && (
size="sm" <p className="ai-search-knowledge-error">
className="w-full" 上一遍同步被取消,已建立的索引仍然可用;下次同步会从断点继续。
disabled={ </p>
syncStarting || )}
knowledgeStatus?.state === 'building' || <div className="ai-search-knowledge-actions">
knowledgeStatus?.state === 'syncing' <Button
} size="sm"
onClick={() => void startKnowledgeSync()} className="ai-search-knowledge-primary"
> disabled={syncStarting || cancelRequested || knowledgeIsRunning}
{syncStarting || onClick={() => void startKnowledgeSync()}
knowledgeStatus?.state === 'building' || >
knowledgeStatus?.state === 'syncing' {syncStarting
? '同步中…' ? '启动中…'
: knowledgeStatus?.state === 'ready' : knowledgeIsRunning
? '同步最新记录' ? '同步中…'
: '建立本地知识库'} : knowledgeStatus?.indexedMessageCount
</Button> ? '同步最新记录'
: '建立本地知识库'}
</Button>
{knowledgeIsRunning && (
<Button
size="sm"
variant="outline"
className="ai-search-knowledge-cancel"
data-testid="knowledge-cancel-sync"
disabled={cancelRequested || knowledgeStatus?.pass?.cancellable === false}
onClick={() => void cancelKnowledgeSync()}
>
{cancelRequested ? '正在取消…' : '取消同步'}
</Button>
)}
</div>
<details className="ai-search-knowledge-more"> <details className="ai-search-knowledge-more">
<summary>同步详情</summary> <summary>同步详情</summary>
<p> <p>
@@ -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<string, string> = {
query_messages: '精确读取',
search_messages: '关键词检索',
conversation_overview: '会话概览',
message_context: '上下文'
}
const SCOPE_LABELS: Record<string, string> = {
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`
}
@@ -1,11 +1,11 @@
import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react' import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import type { Contact } from '../../../../../shared/types'
import type { EvidenceItem } from '../searchTypes' import type { EvidenceItem } from '../searchTypes'
export const EVIDENCE_PAGE_SIZE = 8 export const EVIDENCE_PAGE_SIZE = 8
type UseEvidenceCollectionOptions = { type UseEvidenceCollectionOptions = {
onOpenEvidence: (contact: Contact, createTime?: number) => void /** 传整条证据:跳转需要它的稳定引用(messageRef),不只是会话与时间。 */
onOpenEvidence: (evidence: EvidenceItem) => void
} }
export function useEvidenceCollection({ onOpenEvidence }: UseEvidenceCollectionOptions): { export function useEvidenceCollection({ onOpenEvidence }: UseEvidenceCollectionOptions): {
@@ -70,7 +70,7 @@ export function useEvidenceCollection({ onOpenEvidence }: UseEvidenceCollectionO
const jumpToEvidence = (index: number): void => { const jumpToEvidence = (index: number): void => {
const item = evidenceCollection[index] const item = evidenceCollection[index]
if (!item) return if (!item) return
onOpenEvidence(item.contact, item.message.createTime) onOpenEvidence(item)
} }
const setEvidenceCardRef = (index: number, node: HTMLElement | null): void => { const setEvidenceCardRef = (index: number, node: HTMLElement | null): void => {
@@ -11,10 +11,20 @@ export function useKnowledgeStatus({ dbReady, onNotice }: UseKnowledgeStatusOpti
syncStarting: boolean syncStarting: boolean
knowledgeSyncing: boolean knowledgeSyncing: boolean
knowledgeSyncingRef: React.MutableRefObject<boolean> knowledgeSyncingRef: React.MutableRefObject<boolean>
cancelRequested: boolean
startKnowledgeSync: () => Promise<void> startKnowledgeSync: () => Promise<void>
cancelKnowledgeSync: () => Promise<void>
} { } {
const [knowledgeStatus, setKnowledgeStatus] = useState<KnowledgeRuntimeStatus | null>(null) const [knowledgeStatus, setKnowledgeStatus] = useState<KnowledgeRuntimeStatus | null>(null)
const [syncStarting, setSyncStarting] = useState(false) const [syncStarting, setSyncStarting] = useState(false)
/**
* 本地「取消已发出、但还没落地」的状态。
*
* Worker 侧 abort 之后,当前会话还要安全收尾(事务提交 / 不残留 indexing),
* 主进程的 `pass.cancellable=false` 会先到,`phase='cancelled'` 后到。
* 这中间的窗口如果只靠 status 渲染,按钮会闪回"取消同步"。
*/
const [cancelRequested, setCancelRequested] = useState(false)
const knowledgeSyncingRef = useRef(false) const knowledgeSyncingRef = useRef(false)
const knowledgeSyncing = const knowledgeSyncing =
syncStarting || knowledgeStatus?.state === 'building' || knowledgeStatus?.state === 'syncing' syncStarting || knowledgeStatus?.state === 'building' || knowledgeStatus?.state === 'syncing'
@@ -29,7 +39,11 @@ export function useKnowledgeStatus({ dbReady, onNotice }: UseKnowledgeStatusOpti
}) })
.catch(() => undefined) .catch(() => undefined)
const unsubscribe = window.api.onKnowledgeStatus((status) => { 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 () => { return () => {
active = false active = false
@@ -43,6 +57,7 @@ export function useKnowledgeStatus({ dbReady, onNotice }: UseKnowledgeStatusOpti
return return
} }
setSyncStarting(true) setSyncStarting(true)
setCancelRequested(false)
try { try {
const status = await window.api.startKnowledgeIndex() const status = await window.api.startKnowledgeIndex()
setKnowledgeStatus(status) setKnowledgeStatus(status)
@@ -58,11 +73,44 @@ export function useKnowledgeStatus({ dbReady, onNotice }: UseKnowledgeStatusOpti
} }
} }
/**
* 取消同步。
*
* 关键语义(不能简化成"点一下就当取消成功"):
* - 只有真的中止到了任务,才提示"已取消";
* - `cancelled: false` 表示请求时已经没有可取消的任务(例如刚好自己跑完了),
* 这时候说"已取消"是假话;
* - 已索引数据不会被清空,下次同步会从断点继续。
*/
const cancelKnowledgeSync = async (): Promise<void> => {
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 { return {
knowledgeStatus, knowledgeStatus,
syncStarting, syncStarting,
knowledgeSyncing, knowledgeSyncing,
knowledgeSyncingRef, knowledgeSyncingRef,
startKnowledgeSync cancelRequested,
startKnowledgeSync,
cancelKnowledgeSync
} }
} }
@@ -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<QueryAgentProgressState | null>(null)
const activeRequestRef = useRef<string | null>(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
}
@@ -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<AskWechatConfig>
runAskWechatQuery?: (request: AskWechatQueryRequest) => Promise<AskWechatQueryResult>
forgetAskWechatConversation?: () => Promise<void>
}
const bridge = (): AskWechatBridge => window.api as unknown as AskWechatBridge
export async function resolveQueryAgentEnabled(): Promise<boolean> {
try {
const config = await bridge().getAskWechatConfig?.()
return config?.queryAgentEnabled === true
} catch {
return false
}
}
/** 桥不可用时返回 null,调用方据此走 Legacy。 */
export async function requestAskWechatQuery(
request: AskWechatQueryRequest
): Promise<AskWechatQueryResult | null> {
const run = bridge().runAskWechatQuery
if (!run) return null
return run(request)
}
/** 用户开始新问题时清掉澄清上下文(有界内存,不涉及持久化)。 */
export function forgetAskWechatConversation(): void {
try {
void bridge().forgetAskWechatConversation?.()
} catch {
// 清理失败不影响主流程
}
}
@@ -1,4 +1,5 @@
import type { KnowledgeRuntimeStatus } from '../../../../shared/knowledge' import type { KnowledgeRuntimeStatus } from '../../../../shared/knowledge'
import { isKnowledgeFresh } from '../../../../shared/knowledge'
import type { Contact } from '../../../../shared/types' import type { Contact } from '../../../../shared/types'
import type { SearchTrace } from './searchTypes' import type { SearchTrace } from './searchTypes'
@@ -18,15 +19,67 @@ export const formatMeasuredDuration = (milliseconds: number | undefined): string
export const formatEvidenceTimestamp = (timestamp: number): string => export const formatEvidenceTimestamp = (timestamp: number): string =>
new Date(timestamp).toLocaleString('zh-CN', { hour12: false }) 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 => { export const knowledgeStateLabel = (status: KnowledgeRuntimeStatus | null): string => {
if (!status) return '读取中' if (!status) return '读取中'
return { const phase = status.pass?.phase
unavailable: '未建立', const usable = status.indexedMessageCount > 0 || status.indexedChunkCount > 0
building: '建立中', if (status.state === 'error' || phase === 'error') return usable ? '可用 · 更新失败' : '更新失败'
syncing: '增量同步', if (status.state === 'cancelled' || phase === 'cancelled')
ready: '已同步', return usable ? '可用 · 同步已取消' : '同步已取消'
error: '异常' // 一个分片都没有:这不是"落后",是"还没建立",不该带"可用"前缀。
}[status.state] // `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 => export const contactLabel = (contact: Contact | null | undefined): string =>
@@ -1,4 +1,5 @@
import type { AiSearchFinalEvidence, AiSearchPipelineResult } from '../../../../shared/ai-search' import type { AiSearchFinalEvidence, AiSearchPipelineResult } from '../../../../shared/ai-search'
import { encodeMessageRef } from '../../../../shared/local-query-api'
import type { Contact } from '../../../../shared/types' import type { Contact } from '../../../../shared/types'
import { compactCacheItem } from './searchUtils' import { compactCacheItem } from './searchUtils'
import type { AISearchCacheRecord, EvidenceItem, SearchTrace } from './searchTypes' import type { AISearchCacheRecord, EvidenceItem, SearchTrace } from './searchTypes'
@@ -24,6 +25,9 @@ export const mapPipelineEvidenceItem = (
evidenceId: item.id, evidenceId: item.id,
sourceKind: item.sourceKind, sourceKind: item.sourceKind,
contact, contact,
// 这条路径本来就同时知道真实会话 id 与消息 id,顺手补上稳定引用,
// 让 Legacy / ai-search 证据也能被精确定位(而不是只有 Query Agent 路径能跳准)。
...(safeRef(item.conversationId, item.messageId) || {}),
message: { message: {
id: item.messageId, id: item.messageId,
from: item.senderId || 'user', 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 = ( export const mapPipelineEvidence = (
items: AiSearchFinalEvidence[], items: AiSearchFinalEvidence[],
contacts: Contact[] contacts: Contact[]
@@ -36,6 +36,14 @@ export interface EvidenceItem {
sourceKind?: KnowledgeMessageKind sourceKind?: KnowledgeMessageKind
contact: Contact contact: Contact
message: Message message: Message
/**
* 稳定消息引用(opaque,可还原成 `{conversationId, messageId}`)。
*
* 只靠「会话 + 秒级时间戳」无法定位到**这一条**消息 —— 同一秒可能有多条,
* 而且时间戳只能定位到"附近"。Archive 的跳转优先用它。
* 老缓存记录 / Legacy 路径可能没有它,所以必须是可选的。
*/
messageRef?: string
} }
export interface AISearchCacheRecord { export interface AISearchCacheRecord {
@@ -82,7 +90,13 @@ export interface AISearchWorkspaceProps {
dbReady: boolean dbReady: boolean
aiModelConfig: AIRuntimeModelConfig aiModelConfig: AIRuntimeModelConfig
onSelectContact: (contact: Contact) => void onSelectContact: (contact: Contact) => void
onOpenEvidence: (contact: Contact, createTime?: number) => void /**
* 跳转到证据的原聊天。
*
* 传整条 EvidenceItem 而不是 `(contact, createTime)`:后者丢掉了稳定身份(messageRef),
* 跳转只能靠"会话 + 秒级时间戳"猜,而会话 id 若来自展示层合成的 key 则完全跳不过去。
*/
onOpenEvidence: (evidence: EvidenceItem) => void
onOpenAISettings: () => void onOpenAISettings: () => void
onNotice: (message: string) => void onNotice: (message: string) => void
} }
@@ -287,8 +287,11 @@ export const senderName = (
return contact.type === 'user' ? contact.m_nsNickName || '联系人' : '群成员' 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, evidenceId,
// 稳定引用必须一起进缓存:否则命中缓存后「跳转到原聊天」会退化成按时间戳猜
// (缓存写入是最容易漏掉新字段的地方,这里显式列出而不是展开对象)。
...(messageRef ? { messageRef } : {}),
contact: { contact: {
md5: contact.md5, md5: contact.md5,
m_nsUsrName: contact.m_nsUsrName, m_nsUsrName: contact.m_nsUsrName,
+95 -16
View File
@@ -211,6 +211,11 @@
border: 1px solid hsl(var(--tm-border-subtle)); border: 1px solid hsl(var(--tm-border-subtle));
border-radius: var(--wxex-radius-md); border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated); background: var(--wxex-bg-elevated);
/* 侧栏最窄时卡片正文只有 ~145px。这里保持默认的 word-break / overflow-wrap:
中文长句交给「行栅格 + 显式换行点」处理,不能在词中间乱断,也绝不允许撑破侧栏。 */
min-width: 0;
word-break: normal;
overflow-wrap: normal;
} }
.ai-search-knowledge-card.building, .ai-search-knowledge-card.building,
@@ -226,31 +231,42 @@
border-color: color-mix(in srgb, var(--wxex-warning) 70%, var(--wxex-border)); border-color: color-mix(in srgb, var(--wxex-warning) 70%, var(--wxex-border));
} }
.ai-search-knowledge-card-heading, /* HEADER:左列(品牌 + 状态)可收缩,右侧状态点取自然宽。 */
.ai-search-sync-progress-top, .ai-search-knowledge-heading {
.ai-search-knowledge-details > div { display: grid;
display: flex; grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: start;
justify-content: space-between; column-gap: 8px;
gap: 8px; min-width: 0;
} }
.ai-search-knowledge-card-heading > div { .ai-search-knowledge-heading-text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
min-width: 0;
} }
.ai-search-knowledge-card-heading span:first-child { .ai-search-knowledge-kicker {
color: var(--wxex-text-muted); color: var(--wxex-text-muted);
font-size: 9px; font-size: 9px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.08em; 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); color: var(--wxex-text-primary);
font-size: 12px; font-size: 12px;
font-weight: 700;
line-height: 16px;
word-break: keep-all;
overflow-wrap: break-word;
} }
.ai-search-knowledge-description, .ai-search-knowledge-description,
@@ -266,14 +282,22 @@
color: var(--wxex-warning); color: var(--wxex-warning);
} }
.ai-search-sync-progress { /* CURRENT PASS */
.ai-search-knowledge-pass {
display: grid; display: grid;
gap: 5px; 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); color: var(--wxex-text-secondary);
font-size: 10px; font-size: 10px;
line-height: 15px;
/* 与状态标题同一条折行规则:只在「 · 」之后断句。 */
word-break: keep-all;
overflow-wrap: break-word;
} }
.ai-search-sync-progress-track { .ai-search-sync-progress-track {
@@ -288,6 +312,7 @@
height: 100%; height: 100%;
border-radius: inherit; border-radius: inherit;
background: var(--wxex-brand); background: var(--wxex-brand);
transform-origin: left center;
transition: width 250ms ease; 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; display: grid;
gap: 5px; 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); color: var(--wxex-text-muted);
font-size: 10px; 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); color: var(--wxex-text-secondary);
font-size: 10px; font-size: 10px;
font-variant-numeric: tabular-nums;
font-weight: 600; 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 { .ai-search-knowledge-more {
+119 -2
View File
@@ -100,6 +100,17 @@ export interface KnowledgeConversationInput {
/** true means this is a complete read-only snapshot of the conversation. */ /** true means this is a complete read-only snapshot of the conversation. */
completeSnapshot: boolean completeSnapshot: boolean
messages: KnowledgeSourceMessage[] messages: KnowledgeSourceMessage[]
/**
* 这个会话在**源侧**(WCDB Session.last_timestamp)已经覆盖到的最后活跃时间(epoch ms)。
*
* 与 `KnowledgeIndexRequest.sourceLatestAt` 的区别:后者是整遍 pass 级别的边界,这里是
* per-conversation checkpoint —— 增量 pass 用它判断「这个会话有没有新消息」,
* 从而跳过整个会话(不读 WCDB、不传 IPC、不写索引)。
*
* 记录**源侧**时间而不是「索引里最后一条可建模消息的时间」:否则最后一条恰好落在
* 图片/空正文上的会话会永远被判定为「有新消息」,增量永远跳不过它。
*/
sourceHighWaterTime?: number
} }
export interface KnowledgeIndexRequest { export interface KnowledgeIndexRequest {
@@ -110,6 +121,13 @@ export interface KnowledgeIndexRequest {
fts: KnowledgeFtsConfig fts: KnowledgeFtsConfig
/** Written only after a complete source pass; used for truthful coverage. */ /** Written only after a complete source pass; used for truthful coverage. */
sourceMessageCount?: number sourceMessageCount?: number
/**
* 这一遍完整 pass 实际扫到的源数据最新消息时间(epoch ms)。
*
* 与 `sourceMessageCount` 一样,只在读完全部会话的那一次写入。它是 freshness 的权威口径:
* 拿它与当前源数据最新活跃时间比较,就能确定索引是否已经追上,而不必猜测派生索引的过滤落差。
*/
sourceLatestAt?: number
} }
export interface KnowledgeIndexProgress { export interface KnowledgeIndexProgress {
@@ -236,6 +254,18 @@ export interface KnowledgeSearchTimings {
responseSerializeMs: number responseSerializeMs: number
/** FTS (or short-term database lookup) query time. */ /** FTS (or short-term database lookup) query time. */
ftsMs: number 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. */ /** Reading source message rows from matching chunks. */
messageLoadMs: number messageLoadMs: number
/** Expanding chunk members, scoring terms and per-chunk de-duplication. */ /** Expanding chunk members, scoring terms and per-chunk de-duplication. */
@@ -285,6 +315,13 @@ export interface KnowledgeSearchResult {
evidence: KnowledgeEvidence[] evidence: KnowledgeEvidence[]
indexedMessageCount: number indexedMessageCount: number
indexedChunkCount: number indexedChunkCount: number
/**
* 派生索引里最新的消息时间(epoch ms);null 表示无法判定。
*
* `state: 'ready'` 只说明「这个派生库可以被查询」,**不等于**它已经追到源数据最新位置。
* 调用方必须把它与请求的时间范围比较,才能判断本次检索是否覆盖了用户问的时间。
*/
indexLatestAt: number | null
timings: KnowledgeSearchTimings timings: KnowledgeSearchTimings
conversationRetrieval?: KnowledgeConversationRetrieval conversationRetrieval?: KnowledgeConversationRetrieval
voiceCoverage?: KnowledgeVoiceCoverage voiceCoverage?: KnowledgeVoiceCoverage
@@ -308,9 +345,53 @@ export interface KnowledgeSearchIpcResult extends KnowledgeSearchResult {
source: 'knowledge' | 'fallback' source: 'knowledge' | 'fallback'
totalMessages: number totalMessages: number
fallbackReason?: 'unavailable' | 'indexing' | 'error' 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 { export interface KnowledgeRuntimeStatus {
accountId: string accountId: string
@@ -328,6 +409,20 @@ export interface KnowledgeRuntimeStatus {
walBytes: number walBytes: number
shmBytes: number shmBytes: number
lastError?: string 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 { export interface KnowledgeStatusRequest {
@@ -336,9 +431,31 @@ export interface KnowledgeStatusRequest {
fts: KnowledgeFtsConfig 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<KnowledgeRuntimeStatus, 'indexLatestAt' | 'sourceLatestAt'>
): boolean | null {
if (status.indexLatestAt === null || status.sourceLatestAt === null) return null
return status.indexLatestAt + KNOWLEDGE_FRESHNESS_TOLERANCE_MS >= status.sourceLatestAt
}
export interface KnowledgeWorkerRequest { export interface KnowledgeWorkerRequest {
version: 1 version: 1
type: 'index' | 'preflight' | 'search' | 'status' | 'remove' | 'cancel' | 'close' type: 'index' | 'preflight' | 'search' | 'status' | 'remove' | 'cancel' | 'close' | 'highWater'
requestId: string requestId: string
/** Parent monotonic wall-clock used only for transport timing. */ /** Parent monotonic wall-clock used only for transport timing. */
sentAt?: number sentAt?: number
+222 -8
View File
@@ -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[] = [ 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: '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 时,才允许再检索一次,且每一项都必须与上一次实质不同。', 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: '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: '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 = export type QueryTimeRange =
| { kind: 'all' | 'today' | 'yesterday' | 'this_week' | 'last_7_days' | 'this_month' | 'previous_month' | 'this_year' | 'previous_year' } | { kind: 'all' | 'today' | 'yesterday' | 'this_week' | 'last_7_days' | 'this_month' | 'previous_month' | 'this_year' | 'previous_year' }
| { kind: 'absolute'; startTime?: number; endTime?: number } | { kind: 'absolute'; startTime?: number; endTime?: number }
export interface QueryTarget { query: string } 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<KnowledgeEvidence, 'timestamp' | 'sender' | 'sourceKind' | 'text'> {
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 ResolvedTimeRange { kind: QueryTimeRange['kind']; startTime?: number; endTime?: number; label: string }
export interface QueryMessagesRequest { export interface QueryMessagesRequest {
target: QueryTarget /** 省略 = 使用当前搜索范围(仅当范围恰好只有一个会话时成立)。 */
target?: QueryTarget
timeRange: QueryTimeRange timeRange: QueryTimeRange
direction?: QueryDirection direction?: QueryDirection
messageTypes?: QueryMessageType[] messageTypes?: QueryMessageType[]
order?: QueryOrder order?: QueryOrder
limit?: number limit?: number
excludeSystem?: boolean excludeSystem?: boolean
/** 语料边界;省略 = 不限制(等价于 all)。目标必须落在该边界内,否则被 Host 拒绝。 */
scope?: QueryCorpusScope
} }
export interface QueryMessage { export interface QueryMessage {
messageRef: string messageRef: string
@@ -99,6 +226,8 @@ export interface QueryMessagesResponse {
returnedCount?: number returnedCount?: number
messages?: QueryMessage[] messages?: QueryMessage[]
candidates?: Array<{ displayName: string; type: 'user' | 'group' }> candidates?: Array<{ displayName: string; type: 'user' | 'group' }>
/** 本次实际使用的语料边界。 */
scope?: ResolvedCorpusScope
} }
export interface SearchMessagesRequest { export interface SearchMessagesRequest {
target: QueryTarget target: QueryTarget
@@ -106,25 +235,104 @@ export interface SearchMessagesRequest {
query: string query: string
variants?: string[] variants?: string[]
limit?: number limit?: number
/** 语料边界;省略 = 全部可读会话。省略 target 时用它作为跨会话检索范围。 */
scope?: QueryCorpusScope
} }
export interface SearchMessagesResponse { export interface SearchMessagesResponse {
status: string status: string
target?: { displayName: string; type: 'user' | 'group' } target?: { displayName: string; type: 'user' | 'group' }
resolvedTimeRange?: ResolvedTimeRange resolvedTimeRange?: ResolvedTimeRange
/**
* 本次检索的覆盖度。
*
* `complete` 需要同时满足:派生索引可用 **且** 请求的时间范围被索引完整覆盖
* (索引已追到源数据最新,或 requested range 落在索引覆盖窗口内)。
* 只要源数据在索引之后还有内容,就必须是 `partial` —— 此时 0 条 Evidence
* **不能**被解释成「整个微信里没有」。
*/
coverage?: { state: 'complete' | 'partial' | 'unknown' } coverage?: { state: 'complete' | 'partial' | 'unknown' }
probeCount?: number probeCount?: number
evidenceCount?: number evidenceCount?: number
evidence?: Array<Pick<KnowledgeEvidence, 'timestamp' | 'sender' | 'sourceKind' | 'text'> & { messageRef: string }> evidence?: QueryEvidenceItem[]
candidates?: Array<{ displayName: string; type: 'user' | 'group' }> 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 { export interface MessageContextResponse {
status: string status: string
anchor?: QueryMessage anchor?: QueryMessage
before?: QueryMessage[] before?: QueryMessage[]
after?: QueryMessage[] after?: QueryMessage[]
} }
export interface ConversationOverviewRequest { target: QueryTarget; timeRange: QueryTimeRange } export interface ConversationOverviewRequest { target: QueryTarget; timeRange: QueryTimeRange; scope?: QueryCorpusScope }
export interface ConversationOverviewResponse { export interface ConversationOverviewResponse {
status: string status: string
target?: { displayName: string; type: 'user' | 'group' } target?: { displayName: string; type: 'user' | 'group' }
@@ -135,8 +343,14 @@ export interface ConversationOverviewResponse {
sourceCoverage?: { state: 'complete' | 'partial' | 'unknown'; sourceMessageCount: number } sourceCoverage?: { state: 'complete' | 'partial' | 'unknown'; sourceMessageCount: number }
selection?: { mode: 'temporal_coverage'; selectedEvidenceCount: number; sampled: boolean } selection?: { mode: 'temporal_coverage'; selectedEvidenceCount: number; sampled: boolean }
voiceCoverage?: KnowledgeVoiceCoverage voiceCoverage?: KnowledgeVoiceCoverage
evidence?: Array<Pick<KnowledgeEvidence, 'timestamp' | 'sender' | 'sourceKind' | 'text'> & { messageRef: string }> evidence?: QueryEvidenceItem[]
candidates?: Array<{ displayName: string; type: 'user' | 'group' }> candidates?: Array<{ displayName: string; type: 'user' | 'group' }>
scope?: ResolvedCorpusScope
/**
* 证据来源:`wcdb` = 直接读源数据(会话概览的事实来源);`knowledge` = 派生索引。
* 派生索引可能滞后,故概览以源数据为准。
*/
origin?: 'wcdb' | 'knowledge'
} }
export interface QueryCapabilitiesResponse { export interface QueryCapabilitiesResponse {
version: 1 version: 1
+197
View File
@@ -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<Omit<AiSearchPipelineRequest, 'requestId' | 'text'>>
}
/** 允许回退 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
}
+17
View File
@@ -56,6 +56,23 @@ export interface Message {
exportConversationAvatarUrl?: string 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 TextContent = { type: 'text'; content: string }
type VoiceContent = { type: 'voice'; duration?: number } type VoiceContent = { type: 'voice'; duration?: number }
type LocationContent = { type LocationContent = {
@@ -6,6 +6,7 @@ import {
SEARCH_CACHE_KEY, SEARCH_CACHE_KEY,
buildSearchCacheKey buildSearchCacheKey
} from '../../src/renderer/src/components/search/searchUtils' } from '../../src/renderer/src/components/search/searchUtils'
import { makeSearchResult } from './support/ai-search-fixtures'
const api = { const api = {
getSettings: vi.fn(), getSettings: vi.fn(),
@@ -352,12 +353,18 @@ describe('AISearchWorkspace cache privacy boundary', () => {
expect(input).toHaveValue('') 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({ api.getKnowledgeStatus.mockResolvedValue({
state: 'syncing', state: 'syncing',
indexedMessageCount: 20,
indexedChunkCount: 4,
processedMessages: 20, processedMessages: 20,
totalMessages: 100 totalMessages: 100
}) })
api.runAiSearch.mockResolvedValue(makeSearchResult())
// 本地 Provider:本用例测的是"同步中能否提问",不是远程授权边界
// (远程授权边界由本文件另外的用例覆盖)。
api.getAiSearchProviderStatus.mockResolvedValue({ configured: true, requiresConsent: false })
const onNotice = vi.fn() const onNotice = vi.fn()
render( render(
<AISearchWorkspace <AISearchWorkspace
@@ -378,18 +385,24 @@ describe('AISearchWorkspace cache privacy boundary', () => {
/> />
) )
await userEvent.type(screen.getByRole('textbox'), '同步时不能分析') await userEvent.type(screen.getByRole('textbox'), '同步时也要能分析')
const button = await screen.findByRole('button', { name: /同步中,暂不可分析/ }) // 等状态真的落地(同步中)再点,否则测的是"状态还没到"的竞态而不是产品行为。
expect(button).toBeDisabled() await screen.findByText('Knowledge 可用 · 正在追新')
const form = screen.getByRole('textbox').closest('form') // 同步中**不允许**禁用提问:按钮必须可用,文案也不能变成"暂不可分析"。
expect(form).not.toBeNull() const button = screen.getByRole('button', { name: /开始分析/ })
fireEvent.submit(form as HTMLFormElement) expect(button).toBeEnabled()
expect(screen.queryByRole('button', { name: /暂不可分析/ })).not.toBeInTheDocument()
await userEvent.click(button)
// 查询真的走到了检索(走完 consent 才会到这里),而不是被"请等待同步完成"挡回。
await waitFor(() => expect(api.getAiSearchProviderStatus).toHaveBeenCalled())
await waitFor(() => await waitFor(() =>
expect(onNotice).toHaveBeenCalledWith('知识库正在同步,请等待同步完成后再开始分析') expect(onNotice).toHaveBeenCalledWith('知识库正在后台同步,本次结果可能未覆盖最新消息')
) )
expect(api.getAiSearchProviderStatus).not.toHaveBeenCalled() expect(onNotice).not.toHaveBeenCalledWith('知识库正在同步,请等待同步完成后再开始分析')
expect(api.runAiSearch).not.toHaveBeenCalled() 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 () => { it('submits with Enter and keeps Shift+Enter available for a new line', async () => {
+9 -2
View File
@@ -71,8 +71,15 @@ describe('AISearchComposer', () => {
expect(props.onHistoryOpenChange).toHaveBeenCalledWith(false) 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 }) 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()
}) })
}) })
@@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useEvidenceCollection } from '../../src/renderer/src/components/search/hooks/useEvidenceCollection' import { useEvidenceCollection } from '../../src/renderer/src/components/search/hooks/useEvidenceCollection'
import type { EvidenceItem } from '../../src/renderer/src/components/search/searchTypes' import type { EvidenceItem } from '../../src/renderer/src/components/search/searchTypes'
import { decodeMessageRef, encodeMessageRef } from '../../src/shared/local-query-api'
import { import {
aiSearchContact, aiSearchContact,
makeCacheRecord, makeCacheRecord,
@@ -16,6 +17,9 @@ const makeEvidence = (count: number, offset = 0): EvidenceItem[] =>
return { return {
evidenceId: item.id, evidenceId: item.id,
contact: aiSearchContact, contact: aiSearchContact,
// 稳定引用是"跳转到这一条"的唯一可靠身份:只给会话 + 秒级时间戳,
// 同一秒多条消息时根本定位不到目标。
messageRef: encodeMessageRef(aiSearchContact.md5, item.messageId),
message: { message: {
id: item.messageId, id: item.messageId,
from: item.senderId, from: item.senderId,
@@ -168,14 +172,22 @@ describe('useEvidenceCollection', () => {
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'nearest' }) 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 { result } = renderHook(() => useEvidenceHarness())
const collection = makeEvidence(1) const collection = makeEvidence(1)
act(() => result.current.setEvidenceResult(collection, collection)) act(() => result.current.setEvidenceResult(collection, collection))
act(() => result.current.jumpToEvidence(0)) 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', () => { it('clears the previous request Evidence and selection before the next result is applied', () => {
@@ -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<AskWechatQueryResult, { status: 'answered' }>
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<string, unknown> => ({
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<void> => {
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> = {}): 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(<AISearchWorkspace {...(makeProps() as never)} />)
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(<AISearchWorkspace {...(makeProps() as never)} />)
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(<AISearchWorkspace {...(makeProps() as never)} />)
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(<AISearchWorkspace {...(makeProps() as never)} />)
await submitQuery('BOBO 最近说过什么')
expect(await screen.findByText('Legacy 兜底答案')).toBeTruthy()
})
it('Query Agent 关闭时仍走 Legacy 检索(可回退)', async () => {
api.getAskWechatConfig.mockResolvedValue({ queryAgentEnabled: false })
render(<AISearchWorkspace {...(makeProps() as never)} />)
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(<AISearchWorkspace {...(makeProps() as never)} />)
await submitQuery('我和测试会话最近聊了什么')
expect(await screen.findByText('测试搜索答案')).toBeTruthy()
expect(api.runAiSearch).toHaveBeenCalled()
})
})
describe('AISearchWorkspace — 搜索范围 / 时间 UI / 真实统计与证据', () => {
it('Query Agent 开启时保留搜索范围、隐藏时间范围,并提示时间写在问题里', async () => {
render(<AISearchWorkspace {...(makeProps() as never)} />)
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(<AISearchWorkspace {...(makeProps() as never)} />)
expect(await screen.findByText('时间范围')).toBeTruthy()
expect(screen.getByText('不限时间')).toBeTruthy()
expect(screen.queryByText(/时间直接写在问题里/)).toBeNull()
})
it('把界面选择的搜索范围传给 Query Agent', async () => {
render(<AISearchWorkspace {...(makeProps() as never)} />)
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(<AISearchWorkspace {...(makeProps() as never)} />)
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(<AISearchWorkspace {...(makeProps() as never)} />)
// 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(<AISearchWorkspace {...(makeProps() as never)} />)
expect(await screen.findByText('可用 · 已追至最新')).toBeTruthy()
expect(screen.queryByText('已同步')).toBeNull()
expect(screen.queryByText(/跨会话搜索目前只覆盖到/)).toBeNull()
})
})
const emitProgress = async (
requestId: string,
event: Partial<QueryAgentProgressEvent> & { stage: QueryAgentProgressEvent['stage'] }
): Promise<void> => {
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(<AISearchWorkspace {...(makeProps() as never)} />)
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(<AISearchWorkspace {...(makeProps() as never)} />)
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(<AISearchWorkspace {...(makeProps() as never)} />)
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(<AISearchWorkspace {...(makeProps() as never)} />)
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(<AISearchWorkspace {...(makeProps() as never)} />)
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(<AISearchWorkspace {...(makeProps() as never)} />)
await submitQuery('最近谁聊过健身')
await userEvent.click(await screen.findByRole('button', { name: /取消分析/ }))
// 查询取消与索引取消必须是两套独立的 Abort scope,共用一个会互相误杀。
expect(api.cancelKnowledgeIndex).not.toHaveBeenCalled()
})
})
@@ -6,6 +6,7 @@ import {
SEARCH_CACHE_KEY, SEARCH_CACHE_KEY,
SEARCH_HISTORY_KEY SEARCH_HISTORY_KEY
} from '../../src/renderer/src/components/search/searchUtils' } from '../../src/renderer/src/components/search/searchUtils'
import { decodeMessageRef } from '../../src/shared/local-query-api'
import { import {
aiSearchContact, aiSearchContact,
aiSearchGroup, aiSearchGroup,
@@ -321,7 +322,7 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
expect(api.runAiSearch).toHaveBeenCalledOnce() 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 onOpenEvidence = vi.fn()
const item = makePipelineEvidence(1, aiSearchContact) const item = makePipelineEvidence(1, aiSearchContact)
api.runAiSearch.mockResolvedValue(makeSearchResult({ evidence: [item] })) api.runAiSearch.mockResolvedValue(makeSearchResult({ evidence: [item] }))
@@ -330,7 +331,19 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
await screen.findByText('E1 · 发送者 1') await screen.findByText('E1 · 发送者 1')
await userEvent.click(screen.getByRole('button', { name: '跳转到原聊天 ↗' })) 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 () => { 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) 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([ it.each([
['unavailable', '未建立'], ['unavailable', '未建立', {}],
['building', '建立中'], ['building', '可用 · 正在补齐历史', {}],
['syncing', '增量同步'], ['syncing', '可用 · 正在追新', { pass: makePass('catchup') }],
['ready', '已同步'], ['syncing', '可用 · 正在补齐历史', { pass: makePass('full') }],
['error', '异常'] // backfill(补历史缺口)与 full(首次建库)对用户是同一件事:都不是"追最新"。
] as const)('renders Knowledge state %s as %s', async (state, label) => { ['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({ api.getKnowledgeStatus.mockResolvedValue({
...readyKnowledgeStatus, ...readyKnowledgeStatus,
...overrides,
state, state,
lastError: state === 'error' ? 'Worker 异常' : undefined 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) expect(await screen.findAllByText(new RegExp(`Knowledge ${label}`))).not.toHaveLength(0)
if (state === 'error') expect(screen.getByText('Worker 异常')).toBeInTheDocument() 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 () => { it('starts Knowledge indexing and reflects the returned status', async () => {
api.getKnowledgeStatus.mockResolvedValue({ ...readyKnowledgeStatus, state: 'unavailable' }) api.getKnowledgeStatus.mockResolvedValue({
api.startKnowledgeIndex.mockResolvedValue({ ...readyKnowledgeStatus, state: 'syncing' }) ...readyKnowledgeStatus,
state: 'unavailable',
indexedMessageCount: 0,
indexedChunkCount: 0
})
api.startKnowledgeIndex.mockResolvedValue({
...readyKnowledgeStatus,
state: 'syncing',
pass: makePass('full')
})
const onNotice = vi.fn() const onNotice = vi.fn()
renderWorkspace({ onNotice }) renderWorkspace({ onNotice })
await screen.findByText('Knowledge 未建立') await screen.findByText('Knowledge 未建立')
@@ -607,7 +697,7 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
expect(api.startKnowledgeIndex).toHaveBeenCalledOnce() expect(api.startKnowledgeIndex).toHaveBeenCalledOnce()
expect(onNotice).toHaveBeenCalledWith(expect.stringContaining('开始同步')) 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', () => { it('unsubscribes Knowledge and Progress listeners on unmount', () => {
@@ -617,3 +707,213 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
expect(progressUnsubscribe).toHaveBeenCalledOnce() 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<string, unknown> = {}): unknown => ({
...readyKnowledgeStatus,
state,
...extra
})
const findStateLabel = async (): Promise<HTMLElement> =>
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()
})
})
+117
View File
@@ -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)
})
})
@@ -4,8 +4,9 @@ import type {
AiSearchPipelineResult, AiSearchPipelineResult,
AiSearchTimeRange AiSearchTimeRange
} from '../../src/shared/ai-search' } 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 type { Contact } from '../../src/shared/types'
import { decodeMessageRef, encodeMessageRef } from '../../src/shared/local-query-api'
import { import {
contactLabel, contactLabel,
formatBytes, 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> = {}
): 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(null)).toBe('读取中')
// 一个分片都没有:这不是"落后",是"还没建立"。
expect(knowledgeStateLabel(makeKnowledgeStatus('unavailable'))).toBe('未建立') expect(knowledgeStateLabel(makeKnowledgeStatus('unavailable'))).toBe('未建立')
expect(knowledgeStateLabel(makeKnowledgeStatus('building'))).toBe('建立中') expect(knowledgeStateLabel(makeKnowledgeStatus('building'))).toBe('正在建立')
expect(knowledgeStateLabel(makeKnowledgeStatus('syncing'))).toBe('增量同步') // ready ≠ fresh:这两件事必须在文案里分开。
expect(knowledgeStateLabel(makeKnowledgeStatus('ready'))).toBe('已同步') expect(knowledgeStateLabel(usable('building'))).toBe('可用 · 正在补齐历史')
expect(knowledgeStateLabel(makeKnowledgeStatus('error'))).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', () => { it('keeps the existing contact label fallback order', () => {
@@ -287,6 +329,8 @@ describe('AI Search pipeline evidence mapping', () => {
evidenceId: 'E1', evidenceId: 'E1',
sourceKind: 'text', sourceKind: 'text',
contact: aiSearchContact, contact: aiSearchContact,
// Legacy 路径同样要带稳定引用:否则跳转只能靠"会话 + 秒级时间戳"猜。
messageRef: encodeMessageRef(aiSearchContact.md5, item.messageId),
message: { message: {
id: 'message-1', id: 'message-1',
from: 'sender-1', from: 'sender-1',
@@ -299,6 +343,10 @@ describe('AI Search pipeline evidence mapping', () => {
createTime: Math.floor(item.timestamp / 1_000) 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', () => { it('creates the existing group fallback and voice presentation for an unloaded conversation', () => {
@@ -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> = {}): 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([
'精确读取',
'关键词检索'
])
})
})
+490
View File
@@ -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<ReturnType<QueryAgentProvider['chatWithTools']>>
type ToolExecutor = (name: string, input: Record<string, unknown>) => Promise<Record<string, unknown>>
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<Record<string, unknown>>; tools: string[] }>
} {
const calls: Array<{ messages: Array<Record<string, unknown>>; 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<string, unknown>): 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<Record<string, unknown>>): string =>
JSON.stringify(messages.filter((message) => message.role === 'tool'))
const legacyResult = (patch: Record<string, unknown> = {}): AiSearchPipelineResult =>
({
requestId: 'req-1',
status: 'completed',
answer: 'legacy answer',
...patch
}) as unknown as AiSearchPipelineResult
const answered = (result: AskWechatQueryResult): Extract<AskWechatQueryResult, { status: 'answered' }> => {
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<ToolExecutor>()
.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<Record<string, unknown> | undefined> = []
const execute = vi.fn(async (_name: string, _input: Record<string, unknown>, context?: { conversationScope?: unknown }) => {
seen.push(context?.conversationScope as Record<string, unknown> | 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' })
})
})
+118
View File
@@ -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<KnowledgeRuntimeStatus, 'state' | 'indexLatestAt' | 'sourceLatestAt' | 'pass'>
>
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('可用 · 更新失败')
})
})
File diff suppressed because it is too large Load Diff
+119
View File
@@ -136,6 +136,82 @@ describe('knowledge sqlite', () => {
expect(existsSync(databasePath)).toBe(false) 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 () => { it('uses a bounded exact fallback for two-character Chinese queries with the trigram profile', async () => {
const root = makeRoot() const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts) const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
@@ -352,6 +428,49 @@ describe('knowledge sqlite', () => {
store.close() 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 () => { it('keeps conversation, sender and time filters when a participant question has no topic terms', async () => {
const root = makeRoot() const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts) const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
+350 -11
View File
@@ -19,9 +19,37 @@ vi.mock('../../src/main/services/chat-service', () => ({
import { LocalQueryApiService } from '../../src/main/services/local-query-api-service' import { LocalQueryApiService } from '../../src/main/services/local-query-api-service'
describe('LocalQueryApiService', () => { 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 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 () => { it('publishes capability contract and resolves previous month', async () => {
expect(service.capabilities().tools.query_messages.messageTypes).toContain('file') expect(service.capabilities().tools.query_messages.messageTypes).toContain('file')
@@ -63,22 +91,333 @@ describe('LocalQueryApiService', () => {
expect(searchedRef).toBe(queriedRef) expect(searchedRef).toBe(queriedRef)
await expect(service.context({ messageRef: searchedRef!, before: 0, after: 0 })).resolves.toMatchObject({ status: 'completed' }) 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 overview = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'all' } })
const overviewRef = overview.evidence?.[0]?.messageRef const overviewRef = overview.evidence?.[0]?.messageRef
expect(overviewRef).toBe(queriedRef) expect(overviewRef).toBe(queriedRef)
await expect(service.context({ messageRef: overviewRef!, before: 0, after: 0 })).resolves.toMatchObject({ status: 'completed' }) 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) fixture.contacts.splice(1)
knowledge.search.mockResolvedValueOnce({ state: 'ready', evidence: [ // 陈旧索引的真实形态:索引对这一会话在范围内 0 行,且自称 complete。
{ conversationId: 'md5-bobo', messageId: 'late', timestamp: 300, sender: 'BOBO', sourceKind: 'text', text: 'late' }, knowledge.search.mockResolvedValue({
{ conversationId: 'md5-bobo', messageId: 'early', timestamp: 100, sender: 'BOBO', sourceKind: 'text', text: 'early' } state: 'ready',
], conversationRetrieval: { totalMessages: 82, chunkCount: 4, complete: true }, voiceCoverage: undefined }) evidence: [],
conversationRetrieval: { totalMessages: 0, chunkCount: 0, complete: true },
voiceCoverage: undefined
})
const result = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' } }) const result = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' } })
expect(result.sourceCoverage).toEqual({ state: 'complete', sourceMessageCount: 82 }) expect(result).toMatchObject({ status: 'completed', origin: 'wcdb', sourceMessageCount: 2, evidenceCount: 2 })
expect(result.selection).toEqual({ mode: 'temporal_coverage', selectedEvidenceCount: 2, sampled: true }) expect(result.coverage).toEqual({ state: 'complete' })
expect(result.evidence?.map((item) => item.timestamp)).toEqual([100, 300]) 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<string, unknown> => ({
state: 'ready',
evidence: [],
indexLatestAt: NOW - 10 * DAY,
voiceCoverage: undefined
})
const freshIndex = (evidence: unknown[] = []): Record<string, unknown> => ({
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,}/)
}) })
}) })
@@ -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()
})
})
+93
View File
@@ -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
})
})
})
+121
View File
@@ -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' })
})
})
@@ -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([])
})
})
+77
View File
@@ -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<Record<string, unknown>>; tools: string[] }>
} {
const calls: Array<{ messages: Array<Record<string, unknown>>; 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')
})
})
+38 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' 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 参数解析', () => { describe('Query Agent POC CLI 参数解析', () => {
it('移除开头的分隔符 --', () => { it('移除开头的分隔符 --', () => {
@@ -34,3 +34,40 @@ describe('Query Agent POC CLI 参数解析', () => {
expect(argv).toEqual(['--', '问题']) 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/)
})
})
+2 -2
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { formatProviderDiagnostics, formatTiming } from '../../src/main/query-agent-poc-report' 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> = {}): QueryAgentPocResult { function result(patch: Partial<QueryAgentResult> = {}): QueryAgentResult {
return { return {
question: 'q', question: 'q',
provider: 'Fixture Provider', provider: 'Fixture Provider',
+87
View File
@@ -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> = {}): 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
)
})
})
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest' 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<Awaited<ReturnType<QueryAgentProvider['chatWithTools']>>>, configured = true): QueryAgentProvider { function provider(responses: Array<Awaited<ReturnType<QueryAgentProvider['chatWithTools']>>>, configured = true): QueryAgentProvider {
return { return {
@@ -12,10 +12,10 @@ function provider(responses: Array<Awaited<ReturnType<QueryAgentProvider['chatWi
const MONTH_TEXT = '上个月' const MONTH_TEXT = '上个月'
const MONTH_QUESTION = '上个月 BOBO 有没有给我发过文件' const MONTH_QUESTION = '上个月 BOBO 有没有给我发过文件'
describe('QueryAgentPocService', () => { describe('QueryAgentService', () => {
it('runs a bounded model -> tool -> model loop and records sanitized trace', async () => { 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 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, toolCalls: [{ id: 'call-1', name: 'query_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, temporalBasis: { kind: 'none' }, limit: 1 }) }] },
{ success: true, data: '第一条消息是图片。' } { success: true, data: '第一条消息是图片。' }
]), execute) ]), execute)
@@ -24,7 +24,13 @@ describe('QueryAgentPocService', () => {
expect(result.modelCallCount).toBe(2) expect(result.modelCallCount).toBe(2)
expect(result.toolCallCount).toBe(1) expect(result.toolCallCount).toBe(1)
expect(result.traces[0]).toMatchObject({ toolName: 'query_messages', status: 'completed', resultCount: 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 () => { 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, toolCalls: [{ id: 'call-1', name: 'search_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, queries: ['topic'] }) }] },
{ success: true, data: '根据这条证据可以回答。' } { success: true, data: '根据这条证据可以回答。' }
]) ])
await new QueryAgentPocService(configuredProvider, execute).run('查找相关记录') await new QueryAgentService(configuredProvider, execute).run('查找相关记录')
const calls = vi.mocked(configuredProvider.chatWithTools).mock.calls const calls = vi.mocked(configuredProvider.chatWithTools).mock.calls
const firstMessages = calls[0]?.[0] || [] const firstMessages = calls[0]?.[0] || []
expect(String(firstMessages[0]?.content)).toContain('Evidence 是否已经足以') 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, toolCalls: [{ id: 'call-1', name: 'query_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, temporalBasis: { kind: 'none' }, limit: 1 }) }] },
{ success: true, data: '完成' } { 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([]) 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, toolCalls: [{ id: 'call-2', name: 'conversation_overview', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' } }) }] },
{ success: true, data: '完成' } { success: true, data: '完成' }
]) ])
const result = await new QueryAgentPocService(configuredProvider, execute).run('第一条消息') const result = await new QueryAgentService(configuredProvider, execute).run('第一条消息')
expect(execute).toHaveBeenCalledTimes(1) expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1]).toMatchObject({ toolName: 'conversation_overview', status: 'invalid_tool_arguments' }) expect(result.traces[1]).toMatchObject({ toolName: 'conversation_overview', status: 'invalid_tool_arguments' })
const thirdCallMessages = vi.mocked(configuredProvider.chatWithTools).mock.calls[2]?.[0] || [] 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 () => { it('rejects unknown tools and stops after five calls', async () => {
const execute = vi.fn(async () => ({ status: 'completed' })) const execute = vi.fn(async () => ({ status: 'completed' }))
const responses = Array.from({ length: 6 }, () => ({ success: true, toolCalls: [{ id: 'x', name: 'unknown', arguments: '{}' }] })) 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.toolCallCount).toBe(5)
expect(result.traces.every((trace) => trace.status === 'invalid_tool_arguments')).toBe(true) expect(result.traces.every((trace) => trace.status === 'invalid_tool_arguments')).toBe(true)
expect(result.error).toContain('最大工具调用次数') expect(result.error).toContain('最大工具调用次数')
@@ -87,14 +93,14 @@ describe('QueryAgentPocService', () => {
it('clarifies unavailable configuration without making a model call', async () => { it('clarifies unavailable configuration without making a model call', async () => {
const configuredProvider = provider([], false) 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(result.error).toContain('尚未配置')
expect(configuredProvider.chatWithTools).not.toHaveBeenCalled() expect(configuredProvider.chatWithTools).not.toHaveBeenCalled()
}) })
it('maps LLM queries[] to the Local Query API query + variants contract', async () => { it('maps LLM queries[] to the Local Query API query + variants contract', async () => {
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 0 })) 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, toolCalls: [{ id: 'valid', name: 'search_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, queries: ['答应', '承诺', '保证', '说好'] }) }] },
{ success: true, data: 'done' } { success: true, data: 'done' }
]), execute).run('test') ]), execute).run('test')
@@ -105,7 +111,7 @@ describe('QueryAgentPocService', () => {
expect(execute.mock.calls[0][1]).not.toHaveProperty('queries') expect(execute.mock.calls[0][1]).not.toHaveProperty('queries')
execute.mockClear() 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, toolCalls: [{ id: 'invalid', name: 'search_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, queries: ['1', '2', '3', '4', '5'] }) }] },
{ success: true, data: '修正后完成' } { success: true, data: '修正后完成' }
]), execute).run('test') ]), 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 () => { it('keeps search_messages available once after a zero-evidence search', async () => {
const execute = vi.fn(emptySearch) const execute = vi.fn(emptySearch)
const configured = provider([searchCall('c1', ['答应']), { success: true, data: '没有找到相关证据。' }]) 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']) 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 toolMessage = vi.mocked(configured.chatWithTools).mock.calls[1]?.[0].find((message) => message.role === 'tool')
const presented = JSON.parse(String(toolMessage?.content)) as Record<string, any> const presented = JSON.parse(String(toolMessage?.content)) as Record<string, any>
@@ -244,7 +250,7 @@ describe('QueryAgent zero-result limited retry', () => {
it('allows exactly one substantively different second search and then closes tools', async () => { it('allows exactly one substantively different second search and then closes tools', async () => {
const execute = vi.fn(emptySearch) const execute = vi.fn(emptySearch)
const configured = provider([searchCall('c1', ['答应']), searchCall('c2', ['公积金']), { success: true, data: '仍然没有。' }]) 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(execute).toHaveBeenCalledTimes(2)
expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'completed']) expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'completed'])
expect(vi.mocked(configured.chatWithTools).mock.calls[2]?.[1]).toEqual([]) 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 () => { it('rejects an identical search retry instead of spending the budget again', async () => {
const execute = vi.fn(emptySearch) const execute = vi.fn(emptySearch)
const configured = provider([searchCall('c1', ['答应']), searchCall('c2', ['答应']), { success: true, data: '没有找到。' }]) 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(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' }) expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' })
expect(vi.mocked(configured.chatWithTools).mock.calls[2]?.[1]).toEqual([]) 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 () => { it('treats a reordered identical probe set as an identical retry', async () => {
const execute = vi.fn(emptySearch) const execute = vi.fn(emptySearch)
const configured = provider([searchCall('c1', ['答应', '承诺']), searchCall('c2', ['承诺', '答应']), { success: true, data: 'x' }]) 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(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1].status).toBe('invalid_tool_arguments') 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 () => { 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 execute = vi.fn(async () => ({ status: 'completed', evidenceCount: 2, evidence: [{ messageRef: 'ref', text: 't' }] }))
const configured = provider([searchCall('c1', ['答应']), { success: true, data: 'ok' }]) 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']) 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 () => { it('rejects an identical query_messages retry', async () => {
const execute = vi.fn(emptyQuery) const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'previous_month' }), queryCall('c2', { kind: 'previous_month' }), { success: true, data: 'x' }]) 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(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1].status).toBe('invalid_tool_arguments') 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 () => { it('keeps the efficient stop when the exact query already returned messages', async () => {
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 3, messages: [] })) const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 3, messages: [] }))
const configured = provider([queryCall('c1'), { success: true, data: 'ok' }]) 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([]) 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('c5', { kind: 'this_year' }),
queryCall('c6', { kind: 'this_month' }) 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.toolCallCount).toBe(5)
expect(result.error).toContain('最大工具调用次数') 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 }) }] 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 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(execute).toHaveBeenCalledTimes(2)
expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'completed']) expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'completed'])
// the explicit range is preserved on the retry — only the non-temporal condition changed // 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 () => { it('requires temporalBasis on query_messages', async () => {
const execute = vi.fn(emptyQuery) 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 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(execute).not.toHaveBeenCalled()
expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' }) 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 () => { 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 execute = vi.fn(async () => ({ status: 'completed', returnedCount: 1, messages: [] }))
const configured = provider([queryCall('c1', { kind: 'last_7_days' }, HINT), { success: true, data: 'ok' }]) 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].temporalBasis).toEqual({ kind: 'recall_hint', sourceText: '前阵子' })
expect(result.traces[0].input).not.toHaveProperty('temporalBasis') expect(result.traces[0].input).not.toHaveProperty('temporalBasis')
expect(execute.mock.calls[0][1]).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 () => { it('rejects a sourceText that is not literally in the user question', async () => {
const execute = vi.fn(emptyQuery) const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'previous_month' }, { kind: 'constraint', sourceText: '去年冬天' }), { success: true, data: 'x' }]) 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(execute).not.toHaveBeenCalled()
expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' }) expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' })
expect(JSON.stringify(toolMessageAt(configured, 1))).toContain('source_not_in_question') 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 () => { it('requires sourceText for constraint and recall_hint', async () => {
const execute = vi.fn(emptyQuery) const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'previous_month' }, { kind: 'constraint' }), { success: true, data: 'x' }]) 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(execute).not.toHaveBeenCalled()
expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' }) 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 () => { it('rejects sourceText when kind is none', async () => {
const execute = vi.fn(emptyQuery) const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'all' }, { kind: 'none', sourceText: '上个月' }), { success: true, data: 'x' }]) 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(execute).not.toHaveBeenCalled()
expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' }) expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' })
expect(JSON.stringify(toolMessageAt(configured, 1))).toContain('forbidden_for_none') 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 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 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 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(execute).toHaveBeenCalledTimes(1)
expect(result.traces[0].temporalBasis).toEqual({ kind: 'constraint', sourceText }) 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 () => { it('never broadens a constraint relative range to all when it returns zero', async () => {
const execute = vi.fn(emptyQuery) const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'previous_month' }, CONSTRAINT), { success: true, data: '上个月没有。' }]) 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).toHaveBeenCalledTimes(1)
expect(execute.mock.calls[0][1]).toMatchObject({ timeRange: { kind: 'previous_month' } }) expect(execute.mock.calls[0][1]).toMatchObject({ timeRange: { kind: 'previous_month' } })
expect(result.traces[0].autoFallback).toBeUndefined() expect(result.traces[0].autoFallback).toBeUndefined()
@@ -410,7 +416,7 @@ describe('QueryAgent temporal basis policy', () => {
const execute = vi.fn(emptyQuery) 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 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 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(execute).toHaveBeenCalledTimes(1)
expect(result.traces[0].autoFallback).toBeUndefined() expect(result.traces[0].autoFallback).toBeUndefined()
expect(result.traces[0].input.timeRange).toMatchObject({ kind: 'absolute' }) 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 () => { it('rejects a constraint retry that replaces the user time range', async () => {
const execute = vi.fn(emptyQuery) const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'previous_month' }, CONSTRAINT), queryCall('c2', { kind: 'all' }, CONSTRAINT), { success: true, data: 'x' }]) 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(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' }) expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' })
expect(JSON.stringify(toolMessageAt(configured, 2))).toContain('constraint_time_range_immutable') 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), queryCall('c3', { kind: 'all' }, CONSTRAINT),
{ success: true, data: 'x' } { 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(execute).toHaveBeenCalledTimes(1)
expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'invalid_tool_arguments', 'invalid_tool_arguments']) 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'] }), queryCall('c2', { kind: 'previous_month' }, CONSTRAINT, { messageTypes: ['text'] }),
{ success: true, data: '找到。' } { 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(execute).toHaveBeenCalledTimes(2)
expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'completed']) 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 () => { it('does not broaden when a recall_hint range already returned messages', async () => {
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 3, messages: [] })) 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 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(execute).toHaveBeenCalledTimes(1)
expect(result.traces[0].autoFallback).toBeUndefined() expect(result.traces[0].autoFallback).toBeUndefined()
expect(vi.mocked(configured.chatWithTools).mock.calls[1]?.[1]).toEqual([]) 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: 0, messages: [] })
.mockResolvedValueOnce({ status: 'completed', returnedCount: 1, messages: [{ messageRef: 'ref', sourceKind: 'file' }], resolvedTimeRange: { kind: 'all', label: '全部历史' } }) .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 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).toHaveBeenCalledTimes(2)
expect(execute.mock.calls[1][1]).toMatchObject({ timeRange: { kind: 'all' } }) expect(execute.mock.calls[1][1]).toMatchObject({ timeRange: { kind: 'all' } })
// the corrective lookup is Host orchestration, not a model tool call // 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 () => { it('does not repeat an all-history lookup when a recall_hint query already used all', async () => {
const execute = vi.fn(emptyQuery) const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'all' }, HINT), queryCall('c2', { kind: 'all' }, HINT), { success: true, data: 'x' }]) 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(execute).toHaveBeenCalledTimes(1)
expect(result.traces[0].autoFallback).toBeUndefined() expect(result.traces[0].autoFallback).toBeUndefined()
expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' }) 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: 0, messages: [], query: { resolvedTimeRange: { kind: 'last_7_days', label: '近 7 天' } } })
.mockResolvedValueOnce({ status: 'completed', returnedCount: 1, messages: [{ messageRef: 'ref', text: 'x' }], resolvedTimeRange: { kind: 'all', label: '全部历史' } }) .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' }]) 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) const presented = toolMessageAt(configured, 1)
expect(presented.returnedCount).toBe(0) expect(presented.returnedCount).toBe(0)
expect(presented.fallbackLookup).toMatchObject({ reason: 'soft_temporal_hint_zero_result', timeRange: { kind: 'all' }, returnedCount: 1 }) 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 () => { it('stops after a corrective lookup that also finds nothing', async () => {
const execute = vi.fn(emptyQuery) 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 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 // primary + one corrective lookup only; the third attempt is refused because tools are closed
expect(execute).toHaveBeenCalledTimes(2) expect(execute).toHaveBeenCalledTimes(2)
expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' }) 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 () => { it('accepts none with timeRange all', async () => {
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 2, messages: [] })) const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 2, messages: [] }))
const configured = provider([queryCall('c1', { kind: 'all' }, NONE), { success: true, data: 'ok' }]) 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(execute).toHaveBeenCalledTimes(1)
expect(result.traces[0].temporalBasis).toEqual({ kind: 'none' }) 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 () => { it('rejects none combined with a bounded time range', async () => {
const execute = vi.fn(emptyQuery) const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'last_7_days' }, NONE), { success: true, data: 'x' }]) 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(execute).not.toHaveBeenCalled()
expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' }) expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' })
expect(JSON.stringify(toolMessageAt(configured, 1))).toContain('temporal_basis_mismatch') expect(JSON.stringify(toolMessageAt(configured, 1))).toContain('temporal_basis_mismatch')
@@ -531,7 +537,7 @@ describe('QueryAgent 耗时与请求级诊断记录', () => {
const configured = provider([ const configured = provider([
{ success: false, error: '模型服务返回了网页而不是 JSON(HTTP 502 Bad Gateway)', errorStatus: 502, errorContentType: 'text/html', htmlInsteadOfJson: true, elapsedMs: 99419 } { 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.modelCallCount).toBe(1)
expect(result.modelDurationsMs).toHaveLength(1) expect(result.modelDurationsMs).toHaveLength(1)
expect(result.modelDiagnostics).toEqual([ expect(result.modelDiagnostics).toEqual([
@@ -545,7 +551,7 @@ describe('QueryAgent 耗时与请求级诊断记录', () => {
it('2 次模型调用 + 1 次工具:耗时数组与调用次数一致', async () => { it('2 次模型调用 + 1 次工具:耗时数组与调用次数一致', async () => {
const configured = provider([queryCall('c1'), { success: true, data: '完成' }]) 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.modelCallCount).toBe(2)
expect(result.modelDurationsMs).toHaveLength(2) expect(result.modelDurationsMs).toHaveLength(2)
expect(result.modelDurationsMs.every((value) => typeof value === 'number' && value >= 0)).toBe(true) expect(result.modelDurationsMs.every((value) => typeof value === 'number' && value >= 0)).toBe(true)
@@ -555,7 +561,7 @@ describe('QueryAgent 耗时与请求级诊断记录', () => {
it('1 次模型调用 + 0 次工具:无工具诊断噪声', async () => { it('1 次模型调用 + 0 次工具:无工具诊断噪声', async () => {
const configured = provider([{ success: true, data: '直接回答' }]) 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.modelCallCount).toBe(1)
expect(result.toolCallCount).toBe(0) expect(result.toolCallCount).toBe(0)
expect(result.modelDurationsMs).toHaveLength(1) expect(result.modelDurationsMs).toHaveLength(1)
@@ -567,10 +573,101 @@ describe('QueryAgent 耗时与请求级诊断记录', () => {
queryCall('c1'), queryCall('c1'),
{ success: false, error: 'AI 请求超时', timedOut: true } { 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.modelDurationsMs).toHaveLength(2)
expect(result.modelDiagnostics[1]).toMatchObject({ index: 2, timedOut: true }) expect(result.modelDiagnostics[1]).toMatchObject({ index: 2, timedOut: true })
expect(result.error).toBe('AI 请求超时') expect(result.error).toBe('AI 请求超时')
expect(result.answer).toBeUndefined() 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('直接回答')
})
})