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` 元数据。
`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 不应声称知道图片内容;
- 你只选择了一个群,答案不会自动代表所有聊天。
看到“可能遗漏”或“部分覆盖”时,扩大范围、先完成同步或检查原始媒体后再问。
Knowledge 在后台同步时**不会**暂停分析:你仍然可以提问,只是答案基于当前已可用的覆盖范围。看到“可能遗漏”或“部分覆盖”时,扩大范围、等同步追上或检查原始媒体后再问。
## 这不是事实保证
+13
View File
@@ -41,6 +41,19 @@
你可以点击来源回到档案中的原始消息。产品内部将这些信息称为 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 type { VoiceBatchRequest, VoiceMessageReference } from '../shared/voice-recognition'
import type { AiSearchPipelineRequest } from '../shared/ai-search'
import type {
AskWechatConfig,
AskWechatQueryRequest,
AskWechatQueryResult
} from '../shared/query-agent'
import type { KnowledgeSearchIpcRequest, KnowledgeSearchIpcResult } from '../shared/knowledge'
// 消息身份的规范化在 main / renderer 之间必须一致,所以只从 shared 取一份实现。
import { normalizeMessageIdentity } from '../shared/local-query-api'
import {
isWindowsVcRuntimeMissingError,
WINDOWS_VC_RUNTIME_ERROR_MESSAGE
} from '../shared/windows-runtime'
import { KnowledgeSearchService } from './knowledge/knowledge-search-service'
import {
MESSAGES_AROUND_MAX_WINDOW,
messagesAroundRadii,
normalizeRadiusSeconds,
sliceMessagesAroundWindow,
widenRadiusSeconds
} from './services/messages-around'
import { LocalQueryApiService } from './services/local-query-api-service'
import { AiSearchPipelineService } from './services/ai-search-pipeline-service'
import { QueryAgentService } from './services/query-agent-service'
import { AskWechatService } from './services/ask-wechat-service'
import { createLocalQueryToolExecutor } from './services/local-query-tool-executor'
import { runLegacySafeStorageHelper } from './legacy-safe-storage-helper'
import { runFirstLaunchMigration } from './app-data-migration'
import { WechatShareConfigStore } from './wechat-share-config-store'
@@ -185,6 +202,8 @@ let voiceBatchService: VoiceBatchService | null = null
let knowledgeSearchService: KnowledgeSearchService | null = null
let localQueryApiService: LocalQueryApiService | null = null
let aiSearchPipelineService: AiSearchPipelineService | null = null
let queryAgentService: QueryAgentService | null = null
let askWechatService: AskWechatService | null = null
let imageDecryptService: ImageDecryptService | null = null
let stickerService: StickerService | null = null
let videoAssetService: VideoAssetService | null = null
@@ -618,6 +637,21 @@ app.whenReady().then(async () => {
aiSearchPipelineService = new AiSearchPipelineService(knowledgeSearchService, aiProviderService)
localQueryApiService = new LocalQueryApiService(knowledgeSearchService)
setLocalQueryApiService(localQueryApiService)
// Query Agent:生产 Runtime 只在这里实例化一次,桌面问问微信与 Agent Hub 共用同一个实例。
queryAgentService = new QueryAgentService(
aiProviderService,
createLocalQueryToolExecutor(localQueryApiService)
)
askWechatService = new AskWechatService(queryAgentService, {
entry: 'desktop',
// Legacy 仅在 Runtime 不可恢复错误时使用(见 AskWechatService 的 fallback 规则)。
runLegacy: (request) => {
if (!aiSearchPipelineService) throw new Error('本地搜索服务尚未初始化')
return aiSearchPipelineService.run(request, () => undefined)
},
log: (record) => appLogger.write({ level: record.level, scope: 'query-agent', message: record.message, details: record.details })
})
agentHubService.setQueryAgentService(queryAgentService)
knowledgeSearchService.onStatusChange((status) => {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('knowledge:status', status)
@@ -1150,6 +1184,83 @@ app.whenReady().then(async () => {
}
)
/**
* 「跳转到原聊天」的锚点读取。
*
* 与 `db:getMessages` 的区别:目标是**某一条消息**,不是"某个时间段"。
* 以 `anchorSeconds` 为中心读一个**有界**窗口(默认 ±6h,找不到再放宽到 ±3d),
* 在窗口内按规范化消息 id 精确匹配。
*
* 不整段加载会话历史:最大会话可达数十万条消息,整段读既慢又会挤爆 IPC;
* 而时间窗口在真实数据上是稀疏的,±6h 的量级很小。
*
* `found: false` 表示"已打开会话但无法定位原消息",调用方必须走降级文案,
* 不能假装跳转成功。
*/
ipcMain.handle(
'db:getMessagesAround',
async (
_,
userMd5: string,
messageId: string,
anchorSeconds?: number,
radiusSeconds?: number
) => {
const requestId = nextGetMessagesRequestId()
const startedAt = Date.now()
const target = normalizeMessageIdentity(userMd5, messageId)
if (!target) return { messages: [], found: false, radiusSeconds: 0, truncated: false }
const baseRadius = normalizeRadiusSeconds(radiusSeconds)
const widenRadius = widenRadiusSeconds(baseRadius)
const maxWindowMessages = MESSAGES_AROUND_MAX_WINDOW
const radii = messagesAroundRadii(anchorSeconds, baseRadius, widenRadius)
if (!radii.length) {
// 没有时间锚点时**不能**退化成整段加载会话历史(最大会话可达数十万条,
// 整段读既慢又会挤爆 IPC)。诚实返回"未定位到",由调用方给出降级文案。
wcdbDebugLog(`[${requestId}] IPC db:getMessagesAround skipped reason=no-anchor`)
return { messages: [], found: false, radiusSeconds: 0, truncated: false }
}
wcdbDebugLog(
`[${requestId}] IPC db:getMessagesAround start userMd5=${userMd5} messageId=${target.messageId} anchor=${anchorSeconds || 0}`
)
for (const radius of radii) {
const start = Math.max(0, (anchorSeconds as number) - radius)
const end = (anchorSeconds as number) + radius
let messages: Awaited<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) => {
const snapshot = await chat.getGroupSnapshotAsync(userMd5)
if (snapshot && chat.isReady()) {
@@ -1201,6 +1312,18 @@ app.whenReady().then(async () => {
if (!knowledgeSearchService) throw new Error('本地知识库服务尚未初始化')
return knowledgeSearchService.startCurrentAccountIndex()
})
/**
* 取消正在跑的索引 pass。
*
* - 只中止**索引**:并发的交互查询用另一套 Abort scope,不会被连带取消;
* - 当前 batch 安全收尾:已提交的会话保留,**不回滚**;
* - `run_state` 落到 `cancelled`,不残留 `indexing`;
* - 取消 ≠ 清空:下一次同步从 per-conversation checkpoint 继续,不从头全量重扫。
*/
ipcMain.handle('knowledge:cancelIndex', () => {
if (!knowledgeSearchService) throw new Error('本地知识库服务尚未初始化')
return knowledgeSearchService.cancelCurrentAccountIndex()
})
ipcMain.handle('ai-search:run', (event, request: AiSearchPipelineRequest) => {
if (!aiSearchPipelineService) throw new Error('本地搜索服务尚未初始化')
return aiSearchPipelineService.run(request, (progress) => {
@@ -1218,6 +1341,27 @@ app.whenReady().then(async () => {
}
})
ipcMain.handle('ai-search:getProviderStatus', () => aiProviderService.getAiSearchProviderStatus())
// 问问微信主路径:查询大脑走 Query Agent Runtime(与 Agent Hub 同一实现)。
ipcMain.handle(
'ask-wechat:getConfig',
(): AskWechatConfig => ({ queryAgentEnabled: loadSettings().queryAgentEnabled !== false })
)
ipcMain.handle(
'ask-wechat:query',
(event, request: AskWechatQueryRequest): Promise<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(
'ai-search:authorizeExternalProvider',
(_, request: AiSearchExternalAuthorizationRequest) => {
+527 -70
View File
@@ -1,8 +1,11 @@
import { monitorEventLoopDelay } from 'perf_hooks'
import * as chat from '../services/chat-service'
import type {
KnowledgeAttachmentMetadata,
KnowledgeEvidence,
KnowledgeMessageKind,
KnowledgePassProgress,
KnowledgeRuntimeState,
KnowledgeRuntimeStatus,
KnowledgeSearchRequest,
KnowledgeSearchIpcRequest,
@@ -32,6 +35,42 @@ const MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH = 700
const MAX_SENDER_ENRICHMENT_SESSIONS = 32
const SENDER_ENRICHMENT_SESSION_TTL_MS = 5 * 60 * 1000
/**
* 增量读取时向前回看的 overlap(epoch ms)。
*
* delta 下界 = `checkpoint - DELTA_OVERLAP_MS`,用来吸收 timestamp 边界碰撞。
* 它必须远大于 chunker 的 `maxGapMs`,否则跨越下界的 chunk 会缺前半段消息、重建时丢前文。
*
* ⚠️ 单位:这里是**毫秒**(checkpoint 本身是毫秒)。传给 WCDB 读取层之前必须换成
* epoch **秒**(`chat.listMessagesAsync` 的 start/end 是秒)。混用会让 delta 读恒为空,
* 且**不会报错**。
*/
const DELTA_OVERLAP_MS = 24 * 60 * 60 * 1000
/**
* event-loop 滞后采样的分辨率(ms)。
*
* 用 `monitorEventLoopDelay` 的 histogram 而不是手写 setInterval:后者只能以 interval
* 为粒度发现 stall,给不出分位数。
*/
const LAG_PROBE_RESOLUTION_MS = 10
/** histogram 的纳秒读数转毫秒。 */
function roundMs(nanoseconds: number): number {
if (!Number.isFinite(nanoseconds) || nanoseconds <= 0) return 0
return Math.round(nanoseconds / 1e6)
}
/** WCDB 读取通道:`interactive` = 交互查询,`background` = 后台索引 pass。 */
type WcdbReadLane = 'interactive' | 'background'
type PendingWcdbRead = {
high: boolean
run: () => Promise<unknown>
resolve: (value: unknown) => void
reject: (error: unknown) => void
}
type PendingVoiceTranscriptIndex = {
update: VoiceTranscriptUpdate
waiters: Array<{
@@ -43,7 +82,13 @@ type PendingVoiceTranscriptIndex = {
type SenderEnrichmentSession = {
lastUsedAt: number
contacts?: Awaited<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 {
@@ -203,7 +248,12 @@ export class KnowledgeSearchService {
private readonly statusByAccount = new Map<string, KnowledgeRuntimeStatus>()
private readonly statusListeners = new Set<(status: KnowledgeRuntimeStatus) => void>()
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 wcdbExecutionMsTotal = 0
private voiceTranscriptResolver:
@@ -212,6 +262,17 @@ export class KnowledgeSearchService {
private voiceIndexTail: Promise<void> = Promise.resolve()
private voiceIndexFlushScheduled = false
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) {
this.service = new KnowledgeService(userDataPath, workerPath)
@@ -222,13 +283,32 @@ export class KnowledgeSearchService {
if (!accountId) return this.emptyStatus('')
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
if (this.indexing.has(accountId)) return current
this.cancelRequested = false
const startedAt = Date.now()
this.startLagProbe()
this.passProgress = {
// 已经有分片 = 增量追新;完全没有 = 首次全量建立。
phase: current.indexedChunkCount > 0 || current.indexedMessageCount > 0 ? 'catchup' : 'full',
cancellable: true,
startedAt,
scannedMessages: 0,
indexedMessages: 0,
processedConversations: 0,
totalConversations: 0,
skippedConversations: 0,
catchupConversations: 0,
backfillConversations: 0,
backfillCompletedConversations: 0,
mainLoopLagMs: 0
}
const started: KnowledgeRuntimeStatus = {
...current,
state: current.indexedMessageCount ? 'syncing' : 'building',
processedMessages: 0,
totalMessages: current.sourceMessageCount,
estimatedRemainingMs: null,
lastError: undefined
lastError: undefined,
pass: { ...this.passProgress }
}
this.publishStatus(started)
const task = this.indexAccount(accountId)
@@ -243,6 +323,16 @@ export class KnowledgeSearchService {
})
.finally(() => {
this.indexing.delete(accountId)
this.stopLagProbe()
const finishedPass = this.passProgress
if (finishedPass) {
// 真实结束状态:取消就是取消,绝不留一个假的 indexing。
finishedPass.cancellable = false
finishedPass.mainLoopLagMs = this.lagMaxMs
if (finishedPass.phase !== 'cancelled' && finishedPass.phase !== 'error') {
finishedPass.phase = 'idle'
}
}
void this.refreshStatus(accountId).catch(() => undefined)
})
this.indexing.set(accountId, task)
@@ -252,6 +342,26 @@ export class KnowledgeSearchService {
return started
}
/**
* 取消当前正在跑的索引 pass。
*
* 只中止**索引**(与并发查询是两套独立 Abort scope);当前 batch 安全收尾、
* 已提交会话保留不回滚;`run_state` 落到 `cancelled`,不残留 `indexing`;
* 下一次 catch-up / 手动同步从 per-conversation checkpoint 继续,不从头全量重扫。
*/
async cancelCurrentAccountIndex(): Promise<{ cancellable: boolean; cancelled: boolean }> {
if (!this.indexing.size) return { cancellable: false, cancelled: false }
this.cancelRequested = true
if (this.passProgress) this.passProgress.cancellable = false
const accountId = this.currentAccountId()
if (accountId) {
const current = this.statusByAccount.get(accountId)
if (current) this.publishStatus({ ...current, pass: this.passSnapshot() })
}
const cancelled = await this.service.cancelIndex().catch(() => false)
return { cancellable: true, cancelled }
}
/**
* The voice cache remains owned by the voice pipeline. Knowledge only reads
* a current-account snapshot while constructing a derived local index.
@@ -320,8 +430,10 @@ export class KnowledgeSearchService {
}
async search(request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> {
// 源数据最新活跃时间与索引状态无关,先取一次(零额外 WCDB 调用:读的是已缓存的 Session 列表)。
const sourceLatestAt = this.sourceLatestAt()
const accountId = this.currentAccountId()
if (!accountId) return this.searchFallback(request, 'unavailable')
if (!accountId) return { ...(await this.searchFallback(request, 'unavailable')), sourceLatestAt }
try {
const searchRequest: Omit<KnowledgeSearchRequest, 'databaseRoot'> = {
accountId,
@@ -339,19 +451,81 @@ export class KnowledgeSearchService {
// An existing derived database can answer while its next incremental pass is running.
// Never turn an interactive global search into another full WCDB scan during that pass.
if (result.state === 'ready' || result.evidence.length) {
return this.toKnowledgeResult(result, request.retrievalSessionId)
return { ...(await this.toKnowledgeResult(result, request.retrievalSessionId)), sourceLatestAt }
}
if (this.indexing.has(accountId)) {
return {
...result,
source: 'knowledge',
totalMessages: result.indexedMessageCount
totalMessages: result.indexedMessageCount,
sourceLatestAt
}
}
return this.searchFallback(request, 'unavailable')
return { ...(await this.searchFallback(request, 'unavailable')), sourceLatestAt }
} catch (error) {
console.warn('[Knowledge] search failed, using legacy fallback:', error)
return this.searchFallback(request, 'error')
return { ...(await this.searchFallback(request, 'error')), sourceLatestAt }
}
}
/**
* 源数据最新活跃时间(epoch ms)。派生索引看到不源数据,freshness 判定由它 + `indexLatestAt` 组成。
*/
sourceLatestAt(): number | null {
return chat.getSourceLatestActivityMs()
}
/**
* 上一遍完整索引 pass 的耗时(ms)。0 表示本进程还没有跑完过一遍。
* 调用方用它作为"落后多少才值得再追一遍"的门槛下限,避免在活跃源数据上无限连续索引。
*/
lastPassDurationMs(): number {
return this.lastIndexPassMs
}
/** 当前是否有索引任务在跑(用于「复用当前任务」而不是再启动一个)。 */
isIndexing(): boolean {
// 用 Map 是否为空判断,而不是用当前 accountId 去查:
// `currentAccountId()` 会在 `getSelfAccountInfo()` 就绪前后返回不同的值,
// 只按单键查会漏掉"其实已经有 pass 在跑",于是又启动一个(两个 pass 抢同一个派生库)。
return this.indexing.size > 0
}
/**
* 主动请求一次追赶同步。
*
* 复用现有增量通道(`startCurrentAccountIndex` 内部是增量的:未变化的会话不会重建分片);
* 如果已经在跑就**不**再启动第二个,并把 `triggered` 标为 false,让调用方知道这是复用。
*/
requestCatchUp(minIntervalMs: number): { triggered: boolean; inProgress: boolean } {
const accountId = this.currentAccountId()
if (!accountId) return { triggered: false, inProgress: false }
if (this.isIndexing()) return { triggered: false, inProgress: true }
const now = Date.now()
if (now - this.lastCatchUpRequestedAt < minIntervalMs) {
return { triggered: false, inProgress: false }
}
this.lastCatchUpRequestedAt = now
this.startCurrentAccountIndex()
return { triggered: true, inProgress: this.isIndexing() }
}
/**
* 等当前索引任务结束,最多等 `budgetMs`。返回是否已经结束。
* 全量追赶可能远超查询预算,所以这里必须是**有界**等待,不能无限阻塞交互查询。
*/
async waitForIndexingComplete(budgetMs: number): Promise<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> {
const contacts = await this.listContacts()
let processedMessages = 0
// 整遍后台索引走 background 通道:交互查询可以插到它前面,不至于被 pass 拖慢。
const contacts = await this.listContacts('background')
const startedAt = Date.now()
// 源侧每会话最后活跃时间(零额外 WCDB 调用:直接来自 Session 列表)。
const activity = chat.getConversationActivityMs()
// 每个会话已经索引到哪(per-conversation checkpoint)。
const marks = await this.service
.highWaterMarks({ accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG })
.catch(() => ({}) as Record<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.statusByAccount.get(accountId) || this.emptyStatus(accountId)),
state: this.statusByAccount.get(accountId)?.indexedMessageCount ? 'syncing' : 'building',
processedMessages: 0,
totalMessages: null,
estimatedRemainingMs: null
estimatedRemainingMs: null,
pass: this.passSnapshot()
})
for (const [index, contact] of contacts.entries()) {
let cancelled = false
for (const [index, contact] of orderedContacts.entries()) {
if (this.cancelRequested) {
cancelled = true
break
}
// 交互查询进行中就让路:背景索引绝不能把用户查询拖慢(见 beginInteractiveQuery)。
await this.interactiveIdle
const isBackfill = index >= catchUpContacts.length
if (this.passProgress) {
this.passProgress.phase = isBackfill ? 'backfill' : 'catchup'
this.passProgress.processedConversations = skippedConversations + index
}
const previousActivity = activity.get(contact.md5)
const mark = marks[contact.md5]
// WCDB rejects overlapping async pagination. Queue every archive read so
// background indexing and an interactive fallback search can interleave safely.
const messages = await this.listMessages(contact.md5)
//
// delta 读取:已建立 checkpoint 时只读 checkpoint 之后的源消息(外加有界 overlap),
// 而不是"从历史开头全扫一遍、再判断哪些已经索引过"。
//
// ⚠️ 单位契约见 `DELTA_OVERLAP_MS`:checkpoint 是**毫秒**,传给 WCDB 读取层
// 必须是**秒**,否则 delta 读会被静默过滤成空。
const isDelta = mark !== undefined && mark > 0
const sinceTime = isDelta
? Math.max(0, Math.floor((mark - DELTA_OVERLAP_MS) / 1000))
: undefined
const messages = await this.listMessages(contact.md5, sinceTime, undefined, 'background')
scannedMessages += messages.length
// 这一遍扫到的源数据最新时间:用**未过滤**的原始消息计算,
// 这样「不可建模」的消息(图片/空正文)不会让 freshness 口径偏旧。
let conversationLatest = 0
for (const message of messages) {
const createTime = (message.createTime || 0) * 1000
if (createTime > conversationLatest) conversationLatest = createTime
}
if (conversationLatest > sourceLatestAt) sourceLatestAt = conversationLatest
const sourceMessages = messages
.map((message) => this.toSourceMessage(accountId, contact.md5, message))
.filter((message): message is KnowledgeSourceMessage => Boolean(message))
await this.service.index(
// 记录**源侧**边界(而不是索引里最后一条可建模消息的时间):
// 否则"最后一条恰好落在图片上"的会话会永远被判成有新消息,增量永远跳不过它。
//
// 安全阀:delta 范围读**空**、但源侧声称有新消息 → **不推进** checkpoint。
// 宁可下一遍重试,也不让"一次可疑的空读"升级成"谎报已覆盖"(那会静默丢消息)。
const suspiciousEmptyDelta =
isDelta && messages.length === 0 && (previousActivity ?? 0) > (mark ?? 0)
const sourceHighWaterTime = suspiciousEmptyDelta
? undefined
: Math.max(previousActivity ?? 0, conversationLatest)
const result = await this.service.index(
{
accountId,
conversations: [
{
conversationId: contact.md5,
completeSnapshot: true,
messages: sourceMessages
// delta 模式下绝不能声明"完整快照":否则 store 会把"不在 delta 里的历史消息"
// 误判为被删除,从而整段重建这个会话,增量就白做了。
completeSnapshot: !isDelta,
messages: sourceMessages,
...(sourceHighWaterTime && sourceHighWaterTime > 0 ? { sourceHighWaterTime } : {})
}
],
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
// 只有「这一遍真的读完了全部会话」(没有任何跳过、没有取消)才写入总量口径,
// 否则会把增量 pass 的部分计数冒充成全量。
//
// 这里数的是真正被建模进索引的源消息,不是"扫到的原始条数";后者(含不可建模的
// 图片/空正文)另走 pass 进度里的 scannedMessages。两个数字回答不同问题。
sourceMessageCount:
index === contacts.length - 1 ? processedMessages + sourceMessages.length : undefined
index === orderedContacts.length - 1 && skippedConversations === 0
? indexedMessages + sourceMessages.length
: undefined,
sourceLatestAt:
index === orderedContacts.length - 1 && sourceLatestAt > 0 ? sourceLatestAt : undefined
},
(progress) => {
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
this.publishStatus({
...current,
state: current.indexedMessageCount ? 'syncing' : 'building',
processedMessages: processedMessages + progress.processedMessages,
processedMessages: indexedMessages + progress.processedMessages,
totalMessages: null,
currentConversationId: progress.conversationId,
estimatedRemainingMs: null
estimatedRemainingMs: null,
pass: this.passSnapshot()
})
}
)
processedMessages += sourceMessages.length
if (result.cancelled) {
cancelled = true
indexedMessages += result.processedMessages
break
}
indexedMessages += sourceMessages.length
if (isBackfill) backfillCompletedConversations += 1
if (this.passProgress) {
this.passProgress.scannedMessages = scannedMessages
this.passProgress.indexedMessages = indexedMessages
this.passProgress.processedConversations = skippedConversations + index + 1
this.passProgress.backfillCompletedConversations = backfillCompletedConversations
}
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
this.publishStatus({
...current,
state: current.indexedMessageCount ? 'syncing' : 'building',
processedMessages,
processedMessages: indexedMessages,
totalMessages: null,
currentConversationId: contact.md5,
estimatedRemainingMs: null
estimatedRemainingMs: null,
pass: this.passSnapshot()
})
}
if (cancelled && this.passProgress) this.passProgress.phase = 'cancelled'
// 一遍 pass 一行汇总(低频、只在结束时输出一次)。这些数字同时喂给 UI 的 pass 进度;
// lag 输出分位数而不是单点,因为"最大值看着还行"不能说明没有 stall。
console.info(
`[Knowledge] pass ${cancelled ? 'cancelled' : 'done'} conversations=${contacts.length} ` +
`skipped=${skippedConversations} scanned=${scannedMessages} indexed=${indexedMessages} ` +
`catchup=${catchUpContacts.length} backfill=${backfillContacts.length}/${backfillCompletedConversations} ` +
`elapsedMs=${Date.now() - startedAt} ` +
`lagP50=${this.lagStats?.p50 ?? 0} lagP95=${this.lagStats?.p95 ?? 0} ` +
`lagP99=${this.lagStats?.p99 ?? 0} lagMax=${this.lagStats?.max ?? this.lagMaxMs} ` +
`activityEntries=${activity.size} checkpointMarks=${Object.keys(marks).length}`
)
await this.refreshStatus(accountId, {
processedMessages,
totalMessages: processedMessages,
processedMessages: indexedMessages,
totalMessages: null,
startedAt
})
// 取消的一遍不计入"上一遍耗时",否则查询侧的门槛会被一次提前结束的 pass 带偏。
if (!cancelled) this.lastIndexPassMs = Date.now() - startedAt
}
/** 当前 pass 进度的不可变快照(含实时采样到的主线程滞后)。 */
private passSnapshot(): KnowledgePassProgress | undefined {
if (!this.passProgress) return undefined
// 运行期间也要能读到"此刻为止"的最大滞后,而不是等 pass 结束才有数字。
const liveMax = this.lagHistogram ? roundMs(this.lagHistogram.max) : this.lagMaxMs
return { ...this.passProgress, mainLoopLagMs: Math.max(liveMax, 0) }
}
/**
* 在 pass 开始时启动主线程滞后采样。这是"重活没有压在主线程上"的直接证据。
*/
private startLagProbe(): void {
if (this.lagHistogram) return
this.lagMaxMs = 0
this.lagStats = null
try {
const histogram = monitorEventLoopDelay({ resolution: LAG_PROBE_RESOLUTION_MS })
histogram.enable()
this.lagHistogram = histogram
} catch {
// 采样失败不应该影响索引本身;退化为"没有 lag 数据"。
this.lagHistogram = null
}
}
private stopLagProbe(): void {
const histogram = this.lagHistogram
if (!histogram) return
histogram.disable()
this.lagStats = {
p50: roundMs(histogram.percentile(50)),
p95: roundMs(histogram.percentile(95)),
p99: roundMs(histogram.percentile(99)),
max: roundMs(histogram.max)
}
this.lagMaxMs = Math.max(this.lagMaxMs, this.lagStats.max)
this.lagHistogram = null
}
private async searchFallback(
@@ -502,6 +854,9 @@ export class KnowledgeSearchService {
state: fallbackReason === 'indexing' ? 'indexing' : 'unavailable',
indexedMessageCount: 0,
indexedChunkCount: 0,
// fallback 是直接扫源数据,不走派生索引,因此没有索引覆盖口径可言。
indexLatestAt: null,
sourceLatestAt: this.sourceLatestAt(),
totalMessages,
timings: {
...emptyKnowledgeSearchTimings(),
@@ -639,6 +994,9 @@ export class KnowledgeSearchService {
voiceCoverage.voiceCoverageComplete =
voiceCoverage.voiceMessageCount === voiceCoverage.transcribedVoiceCount
}
const indexLatestParts = partialResults
.map((result) => result.indexLatestAt)
.filter((value): value is number => typeof value === 'number' && value > 0)
return {
state: partialResults.some((result) => result.state === 'ready')
? 'ready'
@@ -647,6 +1005,8 @@ export class KnowledgeSearchService {
: 'unavailable',
indexedMessageCount: Math.max(...partialResults.map((result) => result.indexedMessageCount)),
indexedChunkCount: Math.max(...partialResults.map((result) => result.indexedChunkCount)),
// 多个分片取最新的那个:只要有一部分索引更新,整体覆盖口径就按它算。
indexLatestAt: indexLatestParts.length ? Math.max(...indexLatestParts) : null,
evidence: mergedEvidence,
timings,
voiceCoverage
@@ -680,16 +1040,20 @@ export class KnowledgeSearchService {
}
}
private listContacts(): ReturnType<typeof chat.listContactsAsync> {
return this.enqueueWcdbRead(() => chat.listContactsAsync())
private listContacts(lane: WcdbReadLane = 'interactive'): ReturnType<typeof chat.listContactsAsync> {
return this.enqueueWcdbRead(() => chat.listContactsAsync(), lane)
}
private listMessages(
conversationId: string,
startTime?: number,
endTime?: number
endTime?: number,
lane: WcdbReadLane = 'interactive'
): 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 {
@@ -811,22 +1175,36 @@ export class KnowledgeSearchService {
wcdbExecutionMs: this.wcdbExecutionMsTotal - beforeExecutionMs
},
source: 'knowledge',
totalMessages: result.indexedMessageCount
totalMessages: result.indexedMessageCount,
sourceLatestAt: this.sourceLatestAt()
}
}
/**
* Evidence 的 sender 显示名 enrichment。
*
* 只做「取名字」这一件事:按 conversation 聚合 evidence 真正需要的 wxid(不是整群成员),
* 每群一次批量 name lookup(`getGroupMemberNamesAsync`),**不**构造完整 GroupSnapshot、
* **不** hydrate 头像 —— 后者会把整群成员的头像一起读出来,为拿几个名字付整群成本。
*
* 头像不属于 Query Tool 的成本;若 Evidence UI 将来要头像,走 lazy 路径。
*/
private async enrichEvidenceSenders(
evidence: KnowledgeEvidence[],
retrievalSessionId?: string
): Promise<KnowledgeEvidence[]> {
const candidateConversationIds = Array.from(
new Set(
evidence
.filter((item) => item.senderId && looksLikeOpaqueSenderId(item.sender))
.map((item) => item.conversationId)
)
).slice(0, MAX_SENDER_NAME_CONVERSATIONS)
if (!candidateConversationIds.length) return evidence
// 先按会话聚合需要的 sender,避免"每条 evidence 一次调用"。
const wxidsByConversation = new Map<string, Set<string>>()
for (const item of evidence) {
if (!item.senderId || !looksLikeOpaqueSenderId(item.sender)) continue
let bucket = wxidsByConversation.get(item.conversationId)
if (!bucket) {
bucket = new Set<string>()
wxidsByConversation.set(item.conversationId, bucket)
}
bucket.add(item.senderId)
}
if (!wxidsByConversation.size) return evidence
const session = retrievalSessionId
? this.senderEnrichmentSession(retrievalSessionId)
@@ -836,20 +1214,35 @@ export class KnowledgeSearchService {
const groupConversationIds = new Set(
contacts.filter((contact) => contact.type === 'group').map((contact) => contact.md5)
)
const candidateConversationIds = Array.from(wxidsByConversation.keys())
.filter((conversationId) => groupConversationIds.has(conversationId))
.slice(0, MAX_SENDER_NAME_CONVERSATIONS)
const memberNamesByConversation = new Map<string, Map<string, string>>()
for (const conversationId of candidateConversationIds) {
if (!groupConversationIds.has(conversationId)) continue
let snapshot = session?.groupSnapshots.get(conversationId)
if (!snapshot) {
snapshot = await this.enqueueWcdbRead(() => chat.getGroupSnapshotAsync(conversationId))
session?.groupSnapshots.set(conversationId, snapshot)
const requested = Array.from(wxidsByConversation.get(conversationId) || [])
if (!requested.length) continue
let memberNames = session?.groupMemberNames.get(conversationId)
// 只查缓存里还没有的 wxid —— 同一 session 的后续 probe 因此不会重复读 WCDB。
const missing = requested.filter((wxid) => !memberNames?.has(wxid))
if (missing.length) {
const members = await this.enqueueWcdbRead(() =>
chat.getGroupMemberNamesAsync(conversationId, missing)
)
if (!memberNames) {
memberNames = new Map<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(
(snapshot?.members || [])
.map((member) => [member.wxid, groupMemberDisplayName(member)] as const)
.filter(([, name]) => Boolean(name))
)
if (memberNames.size) memberNamesByConversation.set(conversationId, memberNames)
if (memberNames?.size) memberNamesByConversation.set(conversationId, memberNames)
}
return evidence.map((item) => {
@@ -867,7 +1260,7 @@ export class KnowledgeSearchService {
}
let session = this.senderEnrichmentSessions.get(retrievalSessionId)
if (!session) {
session = { lastUsedAt: now, groupSnapshots: new Map() }
session = { lastUsedAt: now, groupMemberNames: new Map() }
this.senderEnrichmentSessions.set(retrievalSessionId, session)
}
session.lastUsedAt = now
@@ -879,24 +1272,76 @@ export class KnowledgeSearchService {
return session
}
private enqueueWcdbRead<T>(operation: () => Promise<T>): Promise<T> {
const enqueuedAt = Date.now()
const run = async (): Promise<T> => {
const startedAt = Date.now()
this.wcdbQueueMsTotal += Math.max(0, startedAt - enqueuedAt)
try {
return await operation()
} finally {
this.wcdbExecutionMsTotal += Date.now() - startedAt
}
/**
* 交互查询进行中:后台索引会让路。
*
* 追赶同步会自动遍历上千个会话;如果不让路,一次用户查询会和后台 pass 抢同一个
* Worker 与 WCDB 读取通道,被拖到几十秒 —— 查询不能因为索引 backlog 卡住。
*/
beginInteractiveQuery(): void {
if (this.interactiveQueryDepth === 0) {
this.interactiveIdle = new Promise<void>((resolve) => {
this.interactiveIdleResolve = resolve
})
}
const result = this.wcdbReadTail.then(run, run)
// Keep the queue usable after a read failure while returning that failure to its caller.
this.wcdbReadTail = result.then(
() => undefined,
() => undefined
)
return result
this.interactiveQueryDepth += 1
}
endInteractiveQuery(): void {
this.interactiveQueryDepth = Math.max(0, this.interactiveQueryDepth - 1)
if (this.interactiveQueryDepth === 0) {
this.interactiveIdleResolve?.()
this.interactiveIdleResolve = null
}
}
/**
* WCDB 异步分页不允许重叠,所有会话读取都必须串行。
*
* 但**后台索引**与**交互查询**不能同权排队:追赶同步会在后台遍历上千个会话,
* 交互读取排在它后面就会被拖成几十秒。因此分两条通道,交互读取优先于尚未开始的后台读取;
* 交互查询最多只等"一个正在执行的读"(WCDB 不允许重叠,这点无法避免)。
*/
private enqueueWcdbRead<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 {
@@ -911,7 +1356,10 @@ export class KnowledgeSearchService {
estimatedRemainingMs: null,
databaseBytes: 0,
walBytes: 0,
shmBytes: 0
shmBytes: 0,
indexLatestAt: null,
sourceLatestAt: null,
pass: this.passSnapshot()
}
}
@@ -924,20 +1372,29 @@ export class KnowledgeSearchService {
const remote = await this.service.status({ accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG })
const current = this.statusByAccount.get(accountId)
const indexing = this.indexing.has(accountId)
const pass = this.passProgress
const processedMessages =
progress?.processedMessages ?? current?.processedMessages ?? remote.processedMessages
const totalMessages = progress?.totalMessages ?? remote.sourceMessageCount
const state = indexing
// 一遍 pass 结束后,worker 只知道「派生库能不能查」,不知道这一遍是**被取消**还是**出错**。
// 这两个语义只在这里有(worker 侧的 run_state 已经落库),所以由本地 pass 覆盖,
// 避免取消之后又冒充成一个干净的 ready。
const state: KnowledgeRuntimeState = indexing
? remote.indexedMessageCount > 0
? 'syncing'
: 'building'
: remote.state
: pass && (pass.phase === 'cancelled' || pass.phase === 'error')
? pass.phase
: remote.state
const status: KnowledgeRuntimeStatus = {
...remote,
state,
processedMessages,
totalMessages,
estimatedRemainingMs: null
estimatedRemainingMs: null,
// 派生库自己看不到源数据;这里补上源侧最新活跃时间,UI 才能区分 READY 与 FRESH。
sourceLatestAt: this.sourceLatestAt(),
pass: this.passSnapshot()
}
this.publishStatus(status)
return status
+10
View File
@@ -48,6 +48,16 @@ export class KnowledgeService {
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> {
return this.worker.dispose()
}
+183 -16
View File
@@ -191,6 +191,7 @@ export class KnowledgeStore {
async index(
request: Pick<KnowledgeIndexRequest, 'conversations' | 'chunker'> & {
sourceMessageCount?: number
sourceLatestAt?: number
},
signal?: AbortSignal,
onProgress?: (progress: KnowledgeIndexProgress) => void
@@ -242,6 +243,9 @@ export class KnowledgeStore {
this.writeMeta('source_message_count', String(request.sourceMessageCount))
this.refreshStatsSnapshot()
}
if (request.sourceLatestAt !== undefined) {
this.writeMeta('source_latest_at', String(request.sourceLatestAt))
}
this.setRunState('ready')
return {
accountId: this.accountId,
@@ -293,23 +297,64 @@ export class KnowledgeStore {
}
getSearchStatus(): Omit<KnowledgeSearchResult, 'evidence'> {
this.ensureStatsSnapshot()
this.ensureStatsSnapshotForQuery()
const indexedMessageCount = this.readStatNumber('stats_message_count')
const indexedChunkCount = this.readStatNumber('stats_chunk_count')
const runState = this.readMeta('run_state')
return {
state:
runState === 'indexing'
? 'indexing'
: runState === 'ready' && indexedChunkCount > 0
? 'ready'
: 'unavailable',
// READY 的含义是「这个派生库可以被查询」。一次被中断的 pass 会把 run_state 留在
// 'indexing',但已落盘的分片仍然可用 —— 用它当 state 会让可查询的库看起来不可用。
// 「正在同步」由 KnowledgeSearchService 依据真实索引任务表达,不靠这里的残留状态。
state: runState === 'error' ? 'unavailable' : indexedChunkCount > 0 ? 'ready' : 'unavailable',
indexedMessageCount,
indexedChunkCount,
indexLatestAt: this.readIndexLatestAt(),
timings: emptyKnowledgeSearchTimings()
}
}
/**
* 索引已经覆盖到的源数据时间(epoch ms)——「索引更新到哪」的权威口径。
*
* 优先用 per-conversation 的 `source_high_water_time` 聚合(`MAX(...)`):每个**成功处理**
* 的会话都会立刻推进并持久化它(不是等整遍 pass 结束才写),所以增量 pass 同样能让
* freshness 前进。
*
* 为什么这是源侧口径:它是会话最后活跃时间与原始消息 create_time 的最大值,对不可建模的
* 图片/空正文也照算,不是"索引里最新一条可建模消息的时间",所以不会被不可建模消息带偏;
* 并且它在 delta 空读可疑时**不推进**(安全阀在 KnowledgeSearchService 侧),
* 不会把"没读到"谎报成"已覆盖"。
*
* 不用 `source_latest_at` meta 优先:它的写入条件要求「这一遍没有任何跳过」,
* 而增量世界里"有跳过"是常态,于是它会冻结在最后一次全量 pass 的值上,
* 导致 `isKnowledgeFresh()` 恒为 false。
*
* 回退顺序:老库没有该列(或整列为 NULL)→ `source_latest_at` meta →
* `MAX(high_water_time)`(被建模消息的最新时间,只会偏旧,作下限是安全的)。
*/
private readIndexLatestAt(): number | null {
try {
const covered = this.database
.prepare('SELECT MAX(source_high_water_time) AS latest FROM knowledge_index_state')
.get() as DbRow | undefined
const value = Number(covered?.latest)
if (Number.isFinite(value) && value > 0) return value
} catch {
// 老库可能还没有 source_high_water_time 这一列(migration 之前)→ 走回退。
}
const recorded = Number(this.readMeta('source_latest_at'))
if (Number.isFinite(recorded) && recorded > 0) return recorded
try {
const row = this.database
.prepare('SELECT MAX(high_water_time) AS latest FROM knowledge_index_state')
.get() as DbRow | undefined
const value = Number(row?.latest)
return Number.isFinite(value) && value > 0 ? value : null
} catch {
return null
}
}
getRuntimeStatus(): KnowledgeRuntimeStatus {
const search = this.getSearchStatus()
const storage = this.getStorageStats()
@@ -320,7 +365,20 @@ export class KnowledgeStore {
const error = this.readMeta('run_error') || undefined
return {
accountId: this.accountId,
state: runState === 'error' ? 'error' : search.state === 'ready' ? 'ready' : 'unavailable',
// 注意区分三种「不是 ready」:
// - 'error' :这一遍真的失败了;
// - 'cancelled' :用户主动取消(已提交的会话保留、可继续,绝不留一个假的 indexing);
// - 'unavailable':还没有任何可用分片。
// 这里**不**用 `search.state`(那是「能不能查」):取消后派生库仍然可查,
// 但 UI 需要区分「可用 · 已追至最新」与「可用 · 同步已取消」。
state:
runState === 'error'
? 'error'
: runState === 'cancelled'
? 'cancelled'
: search.state === 'ready'
? 'ready'
: 'unavailable',
indexedMessageCount: search.indexedMessageCount,
indexedChunkCount: search.indexedChunkCount,
sourceMessageCount,
@@ -330,7 +388,10 @@ export class KnowledgeStore {
databaseBytes: storage.databaseBytes,
walBytes: storage.walBytes,
shmBytes: storage.shmBytes,
lastError: error
lastError: error,
// sourceLatestAt 属于源数据(WCDB),派生库本身看不到,由 KnowledgeSearchService 补齐。
indexLatestAt: search.indexLatestAt,
sourceLatestAt: null
}
}
@@ -345,6 +406,7 @@ export class KnowledgeStore {
} {
const startedAt = Date.now()
let ftsMs = 0
let shortTermSearchMs = 0
let messageLoadMs = 0
let chunkExpandMs = 0
let rankingMs = 0
@@ -505,6 +567,7 @@ export class KnowledgeStore {
timings: {
...emptyKnowledgeSearchTimings(),
ftsMs,
shortTermSearchMs,
messageLoadMs,
chunkExpandMs,
rankingMs,
@@ -733,7 +796,9 @@ export class KnowledgeStore {
searchWithStatus(query: KnowledgeQuery): KnowledgeSearchResult {
const startedAt = Date.now()
const statusStartedAt = Date.now()
const status = this.getSearchStatus()
const statusMs = Date.now() - statusStartedAt
const measured = status.indexedChunkCount > 0 ? this.searchMeasured(query) : null
const voiceStartedAt = Date.now()
const voiceCoverage = this.getVoiceCoverage(query)
@@ -750,6 +815,7 @@ export class KnowledgeStore {
totalMs: workerExecutionMs,
globalCountMs: statsRefreshMs,
voiceCoverageMs,
statusMs,
workerExecutionMs
},
conversationRetrieval: measured?.conversationRetrieval,
@@ -837,6 +903,15 @@ export class KnowledgeStore {
) STRICT;
CREATE INDEX IF NOT EXISTS knowledge_messages_conversation_time
ON knowledge_messages (conversation_id, create_time);
-- 跨会话 lexical probe 的短词回退路径是「全表 LIKE + ORDER BY create_time DESC LIMIT k」。
-- 没有这个索引时 SQLite 只能 SCAN + TEMP B-TREE,代价随表增长线性上升;
-- 有了它就能按时间倒序走索引并提前终止(同一 ORDER BY / 同一 LIMIT,结果集完全一致),
-- 降到毫秒级。它不改变任何检索语义,只是让同一条 SQL 有可用的访问路径。
CREATE INDEX IF NOT EXISTS knowledge_messages_time
ON knowledge_messages (create_time);
-- 语音覆盖聚合按 (kind, conversation_id[, create_time]) 过滤;没有它就只能全表扫。
CREATE INDEX IF NOT EXISTS knowledge_messages_kind_conversation
ON knowledge_messages (kind, conversation_id);
CREATE TABLE IF NOT EXISTS knowledge_chunks (
rowid INTEGER PRIMARY KEY,
chunk_id TEXT NOT NULL UNIQUE,
@@ -871,6 +946,9 @@ export class KnowledgeStore {
if (!stateColumns.has('complete_snapshot')) {
this.database.exec('ALTER TABLE knowledge_index_state ADD COLUMN complete_snapshot INTEGER NOT NULL DEFAULT 0')
}
if (!stateColumns.has('source_high_water_time')) {
this.database.exec('ALTER TABLE knowledge_index_state ADD COLUMN source_high_water_time INTEGER')
}
const messageColumns = new Set(
asRows(this.database.prepare('PRAGMA table_info(knowledge_messages)').all()).map((row) =>
String(row.name)
@@ -959,7 +1037,24 @@ export class KnowledgeStore {
}
}
}
if (changedAt < 0) return { chunkCount: 0, updatedChunks: 0 }
if (changedAt < 0) {
// 内容没有变化 → 不必重建分片。但仍然要记下这一遍扫到的**源侧**边界,
// 否则下一次增量 pass 又会因为缺少标记而重读这个会话(永远无法跳过)。
//
// 单调推进:checkpoint 只应该前进。让一个"看起来更旧"的值覆盖它,会把已经追到最新的
// 会话重新打回"有新消息",于是每一遍都白读一次,还会让 freshness 误判回退。
if (conversation.sourceHighWaterTime !== undefined) {
this.database
.prepare(
`UPDATE knowledge_index_state
SET source_high_water_time = MAX(COALESCE(source_high_water_time, 0), ?),
updated_at = ?
WHERE conversation_id = ?`
)
.run(conversation.sourceHighWaterTime, Date.now(), conversation.conversationId)
}
return { chunkCount: 0, updatedChunks: 0 }
}
const rebuildStart = Math.max(0, changedAt - chunker.overlapMessages)
const boundaryTime = normalized[rebuildStart]?.createTime ?? 0
@@ -1021,7 +1116,8 @@ export class KnowledgeStore {
highWater,
normalized.length,
null,
conversation.completeSnapshot
conversation.completeSnapshot,
conversation.sourceHighWaterTime ?? null
)
this.database.exec('COMMIT')
return { chunkCount: chunks.length, updatedChunks: chunks.length }
@@ -1162,14 +1258,20 @@ export class KnowledgeStore {
highWater: number | null,
messageCount: number,
error: string | null = null,
completeSnapshot = false
completeSnapshot = false,
/**
* 这一遍从 WCDB 读到的**原始**最新 create_time(epoch ms,未经过滤)。
* 与 `highWater`(索引里最后一条可建模消息的时间)不同:图片等不可建模消息会被后者漏掉,
* 于是「最后一条恰好是图片」的会话每次都会被认为是"有新消息"。源侧边界没有这个问题。
*/
sourceHighWater: number | null = null
): void {
this.database
.prepare(
`INSERT INTO knowledge_index_state (
conversation_id, account_id, chunker_version, state, high_water_time,
indexed_message_count, complete_snapshot, last_error, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
indexed_message_count, complete_snapshot, last_error, updated_at, source_high_water_time
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(conversation_id) DO UPDATE SET
account_id = excluded.account_id,
chunker_version = excluded.chunker_version,
@@ -1178,7 +1280,14 @@ export class KnowledgeStore {
indexed_message_count = excluded.indexed_message_count,
complete_snapshot = excluded.complete_snapshot,
last_error = excluded.last_error,
updated_at = excluded.updated_at`
updated_at = excluded.updated_at,
-- 单调推进 + 保留 NULL 语义:新值为 NULL 时保持旧值;否则取两者较大者。
-- 若退化成写 0,readSourceHighWaterMarks() 的 IS NOT NULL 就会把该会话
-- 当成"有 checkpoint 但等于 0",从而每遍都误走 backfill 全量读。
source_high_water_time = CASE
WHEN excluded.source_high_water_time IS NULL THEN knowledge_index_state.source_high_water_time
ELSE MAX(COALESCE(knowledge_index_state.source_high_water_time, 0), excluded.source_high_water_time)
END`
)
.run(
conversationId,
@@ -1189,10 +1298,31 @@ export class KnowledgeStore {
messageCount,
completeSnapshot ? 1 : 0,
error,
Date.now()
Date.now(),
sourceHighWater
)
}
/**
* 每个会话「已经索引到源数据的哪个时刻」(epoch ms)。
*
* 增量 pass 用它判断哪些会话真的需要重新读取:Session 行的 `last_timestamp` 不晚于这个值
* 就说明没有新消息,可以直接跳过(不读 WCDB、不写索引)。
*/
readSourceHighWaterMarks(): Record<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 {
const row = this.database.prepare('SELECT value FROM knowledge_meta WHERE key = ?').get(key) as
| DbRow
@@ -1226,6 +1356,43 @@ export class KnowledgeStore {
this.refreshStatsSnapshot()
}
/**
* 搜索热路径专用的快照读取(**不做全表聚合**)。
*
* 分开的原因:`markStatsStale()` 在**每次** `index()` 调用时都会执行,而
* `KnowledgeSearchService.indexAccount` 是**逐会话**调用 `index()` 的,所以一遍后台 pass
* 进行中 `stats_state` 几乎永远是 `'stale'`,pass 被中断后更是会一直留在 `'stale'`。
* 如果每次搜索都因此重做三次全表聚合(messages / chunks / voice),单个 probe 的代价就是
* 数十秒级,交互查询会被卡住。
*
* 规则:只有在快照**从未建立**(首库)或**明显与真实数据不符**(快照说 0 个分片、
* 但库里确实有分片 → 会把可查询的库误报成 unavailable)时才刷新。其余情况沿用上一次完整
* pass 写下的结论:数字可能略旧,但不会把可查询的库说成不可用,也不会把交互查询卡在
* 全表聚合上。
*/
private ensureStatsSnapshotForQuery(): void {
const state = this.readMeta('stats_state')
if (state === 'fresh') return
if (state === null) {
this.refreshStatsSnapshot()
return
}
if (this.readStatNumber('stats_chunk_count') === 0 && this.hasAnyChunk()) {
this.refreshStatsSnapshot()
}
}
/** 只探一行,用于避免把"有分片但快照过期"的库报成不可用。 */
private hasAnyChunk(): boolean {
try {
return Boolean(
this.database.prepare('SELECT 1 AS present FROM knowledge_chunks LIMIT 1').get()
)
} catch {
return false
}
}
private markStatsStale(): void {
this.writeMeta('stats_state', 'stale')
}
+31 -2
View File
@@ -19,6 +19,7 @@ type WorkerResult =
| KnowledgeCapacityPreflight
| KnowledgeSearchResult
| KnowledgeRuntimeStatus
| { marks: Record<string, number> }
| { removed: true }
type PendingRequest = {
resolve: (result: WorkerResult) => void
@@ -36,6 +37,14 @@ export class KnowledgeWorkerHost {
private child: ChildProcess | null = null
private childStartedAt = 0
private readonly pending = new Map<string, PendingRequest>()
/**
* 当前在跑的索引请求 id。
*
* 之前没有它,所以「取消同步」在 UI 上不存在、在主进程里也无法表达 ——
* 唯一能停下来的方式就是退出应用。这里显式跟踪,`cancelActiveIndex()` 才能
* 精确地只中止索引,而**不会**影响任何并发进行的查询请求。
*/
private activeIndexRequestId: string | null = null
constructor(private readonly workerPath: string) {}
@@ -43,7 +52,9 @@ export class KnowledgeWorkerHost {
payload: KnowledgeIndexRequest,
onProgress?: (progress: KnowledgeIndexProgress) => void
): Promise<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> {
@@ -58,6 +69,21 @@ export class KnowledgeWorkerHost {
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 }> {
return this.request('remove', { accountId, databaseRoot }) as Promise<{ removed: true }>
}
@@ -81,13 +107,15 @@ export class KnowledgeWorkerHost {
private request(
type: KnowledgeWorkerRequest['type'],
payload: KnowledgeWorkerRequest['payload'],
onProgress?: (progress: KnowledgeIndexProgress) => void
onProgress?: (progress: KnowledgeIndexProgress) => void,
onRequestId?: (requestId: string) => void
): Promise<WorkerResult> {
const hadWorker = Boolean(this.child?.connected)
const child = this.ensureChild()
const requestId = randomUUID()
const sentAt = Date.now()
const request: KnowledgeWorkerRequest = { version: 1, type, requestId, sentAt, payload }
onRequestId?.(requestId)
return new Promise((resolve, reject) => {
this.pending.set(requestId, {
resolve,
@@ -145,6 +173,7 @@ export class KnowledgeWorkerHost {
const pending = this.pending.get(requestId)
if (!pending) return
this.pending.delete(requestId)
if (this.activeIndexRequestId === requestId) this.activeIndexRequestId = null
if (error) pending.reject(error)
else if (result) pending.resolve(this.applyTransportTimings(result, pending, transport))
else pending.reject(new Error('Knowledge worker returned no result'))
+26 -1
View File
@@ -114,6 +114,7 @@ async function handleSearch(
evidence: [],
indexedMessageCount: 0,
indexedChunkCount: 0,
indexLatestAt: null,
timings: emptyKnowledgeSearchTimings()
},
workerReceivedAt,
@@ -153,7 +154,9 @@ async function handleStatus(
estimatedRemainingMs: null,
databaseBytes: 0,
walBytes: 0,
shmBytes: 0
shmBytes: 0,
indexLatestAt: null,
sourceLatestAt: null
}
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
return
@@ -166,6 +169,24 @@ async function handleStatus(
})
}
/**
* 每个会话「已经索引到源数据的哪个时刻」。
* 增量 pass 靠它决定哪些会话可以整段跳过(见 `KnowledgeStore.readSourceHighWaterMarks`)。
*/
async function handleHighWater(
request: KnowledgeWorkerRequest,
payload: KnowledgeStatusRequest
): Promise<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> {
try {
if (request.type === 'cancel') {
@@ -201,6 +222,10 @@ async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number
await handleStatus(request, request.payload as KnowledgeStatusRequest)
return
}
if (request.type === 'highWater') {
await handleHighWater(request, request.payload as KnowledgeStatusRequest)
return
}
if (request.type === 'index') {
await handleIndex(request, request.payload as KnowledgeIndexRequest)
return
+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 {
const args = [...argv]
// npm / pnpm 在复合 script(`build && electron ...`)里会把 `--` 一并追加到命令末尾,
@@ -11,3 +18,80 @@ export function parsePocQuestion(argv: readonly string[]): string {
if (args[0] === '--') args.shift()
return args.join(' ').trim()
}
export type PocInvocation =
| { kind: 'question'; question: string; scope?: QueryCorpusScope; pretty: boolean }
| { kind: 'tool'; toolName: string; args: Record<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 } from 'electron'
import { apiTokenStore } from './api-token-store'
import { AIProviderService } from './services/ai-provider-service'
import { QueryAgentPocService, type QueryAgentToolResult } from './services/query-agent-poc-service'
import { parsePocQuestion } from './query-agent-poc-cli'
import { QueryAgentService, type QueryAgentToolResult } from './services/query-agent-service'
import { parsePocInvocation } from './query-agent-poc-cli'
import { formatProviderDiagnostics, formatTiming } from './query-agent-poc-report'
import type { QueryCorpusScope } from '../shared/local-query-api'
/** CLI 侧没有联系人列表,范围说明只能是通用文案(生产由 UI 提供带名字的 label)。 */
const describePocScope = (scope: QueryCorpusScope): string => {
if (scope.kind === 'all') return '所有聊天记录'
if (scope.kind === 'groups') return '群聊专属(全部群聊)'
return scope.kind === 'contact' ? '单聊专属(指定联系人)' : '当前会话'
}
const baseUrl = (process.env.TRACEMEMO_QUERY_API_BASE || 'http://127.0.0.1:6131/api/v1').replace(/\/+$/, '')
const question = parsePocQuestion(process.argv.slice(2))
const invocation = parsePocInvocation(process.argv.slice(2))
async function callQueryApi(name: string, input: Record<string, unknown>): Promise<QueryAgentToolResult> {
const paths: Record<string, string> = {
@@ -26,11 +41,34 @@ async function callQueryApi(name: string, input: Record<string, unknown>): Promi
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> {
await app.whenReady()
if (invocation.kind === 'tool') {
// 裸工具诊断:直接调 Local Query API,用来在没有 GUI 的情况下审计引擎侧真实数据。
const toolResult = await callQueryApi(invocation.toolName, invocation.args)
process.stdout.write(`${JSON.stringify(toolResult, null, 2)}\n`)
app.quit()
return
}
const provider = new AIProviderService()
const service = new QueryAgentPocService(provider, callQueryApi)
const result = await service.run(question)
const service = new QueryAgentService(provider, executePocTool)
const result = await service.run(invocation.question, {
...(invocation.scope ? { conversationScope: { scope: invocation.scope, label: describePocScope(invocation.scope) } } : {})
})
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
// 诊断摘要写 stderr,保持 stdout 仍是纯 JSON,方便管道与脚本消费。
process.stderr.write(formatTiming(result))
+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
@@ -16,7 +16,7 @@ function row(label: string, value: string): string {
* - 3 次以上(无法逐段归属工具耗时时,明确标注为聚合)
* - 首次模型调用失败 / 末尾模型调用失败
*/
export function formatTiming(result: QueryAgentPocResult): string {
export function formatTiming(result: QueryAgentResult): string {
const durations = result.modelDurationsMs || []
const toolTotalMs = result.toolTotalMs || 0
const toolCount = result.toolCallCount || 0
@@ -52,7 +52,7 @@ export function formatTiming(result: QueryAgentPocResult): string {
* 请求级诊断。
* 只输出 host 与状态字段;绝不输出 API key / Authorization / 完整 URL / 响应正文。
*/
export function formatProviderDiagnostics(result: QueryAgentPocResult, host?: string): string {
export function formatProviderDiagnostics(result: QueryAgentResult, host?: string): string {
const lines = [
'',
'[Provider]',
+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 { generateAgentGroupReport } from './agent-group-report-service'
import { AIProviderService } from './ai-provider-service'
import { QueryAgentService } from './query-agent-service'
import { AskWechatService } from './ask-wechat-service'
import {
QUERY_AGENT_UNAVAILABLE_TEXT,
queryAgentReplyText,
resolveInboundRoute,
type GroupMemberChatIntent,
type GroupReportIntent
} from './agent-hub-routing'
import { isPackagedRuntime } from '../runtime-mode'
import {
getGroupSnapshot,
@@ -53,30 +62,6 @@ export interface AgentHubNotificationResult {
error?: string
}
interface GroupReportIntent {
group: string
range: 'today' | 'yesterday' | '7days'
}
interface ContactChatIntent {
contact: string
limit: number
summarize: boolean
}
interface GroupMemberChatIntent {
group: string
member: string
range: 'today' | 'yesterday' | '7days'
days: number
goal: string
}
interface NaturalLanguageResult {
command?: string
reply?: string
}
const agentAIProvider = new AIProviderService()
function resolveBundledBinary(
@@ -127,6 +112,24 @@ export class AgentHubService {
updatedAt: Date.now()
}
/**
* 查询大脑。由主进程注入**同一个** QueryAgentRuntime 实例(桌面问问微信也用它),
* Agent Hub 只负责把微信问题送进去、把回答发回去。
*/
private queryAgent: AskWechatService | null = null
/**
* 注入生产 Query Agent Runtime(桌面与微信机器人共用同一实现,避免第二套 Query 语义)。
*/
setQueryAgentService(runtime: QueryAgentService): void {
this.queryAgent = new AskWechatService(runtime, {
entry: 'agent-hub',
// Agent Hub 没有 Legacy AI Search 通道:查询失败时给出明确文案,绝不误触 Report Action。
log: (record) =>
this.addLog('agent-hub', record.level === 'info' ? 'info' : record.level, record.message)
})
}
async start(settings: AppSettings): Promise<boolean> {
void settings
this.stopping = false
@@ -397,301 +400,82 @@ export class AgentHubService {
.join(' ')
this.addLog('agent-hub', 'info', `收到微信消息 message_id=${messageId || 'unknown'}`)
const reportIntent = this.matchGroupReportIntent(text)
// 三路边界:明确产物 → Report / 成员分析 Action;会话列表 → 确定性能力;其余 → Query Agent。
// 注意:这里**不再**先跑意图分类 LLM,查询类问题直接进入 Query Agent(避免双重 LLM 语义系统)。
const route = resolveInboundRoute(text)
if (reportIntent) {
if (route.kind === 'report_action') {
if (messageId) this.processedMessages.set(messageId, Date.now())
this.addLog(
'agent-hub',
'info',
`匹配群聊总结:${reportIntent.group}(${reportIntent.range})`
`匹配群聊总结:${route.intent.group}(${route.intent.range})`
)
await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch((error) => {
this.addLog('agent-hub', 'warn', `等待提示发送失败:${this.errorMessage(error)}`)
})
void this.generateAndSendReport(inbound, reportIntent)
void this.generateAndSendReport(inbound, route.intent)
return this.sendHubJson(response, 202, { status: 'generating' })
}
const groupMemberIntent = this.matchGroupMemberChatIntent(text)
if (groupMemberIntent) {
if (route.kind === 'group_member_action') {
if (messageId) this.processedMessages.set(messageId, Date.now())
void this.summarizeGroupMemberChat(inbound, groupMemberIntent)
void this.summarizeGroupMemberChat(inbound, route.intent)
return this.sendHubJson(response, 202, { status: 'generating', mode: 'group-member-summary' })
}
const contactChatIntent = this.matchContactChatIntent(text)
if (contactChatIntent) {
if (messageId) this.processedMessages.set(messageId, Date.now())
if (contactChatIntent.summarize) {
void this.summarizeContactChat(inbound, contactChatIntent)
return this.sendHubJson(response, 202, { status: 'generating', mode: 'contact-summary' })
if (route.kind === 'recent_list') {
if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' })
const items = listRecentChat(route.limit)
const lines = items.map((item, index) => {
const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
return `${index + 1}. ${name}(${item.type === 'group' ? '群聊' : '联系人'})`
})
const reply = lines.length
? `最近 ${items.length} 个会话:\n${lines.join('\n')}`
: '暂时没有找到最近会话。'
try {
await this.sendConnector(inbound, reply)
} catch (error) {
this.addLog('agent-hub', 'error', `回复发送失败:${this.errorMessage(error)}`)
return this.sendHubJson(response, 502, { error: 'reply delivery failed' })
}
await this.replyContactChat(inbound, contactChatIntent)
if (messageId) this.processedMessages.set(messageId, Date.now())
this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`)
return this.sendHubJson(response, 200, { status: 'ok' })
}
const recentChatLimit = this.matchRecentChatIntent(text)
if (recentChatLimit === null && text.trim()) {
if (messageId) this.processedMessages.set(messageId, Date.now())
void this.handleNaturalLanguage(inbound, text)
return this.sendHubJson(response, 202, {
status: 'processing',
mode: 'natural-language'
})
}
if (recentChatLimit === null) {
this.addLog('agent-hub', 'info', '消息已忽略:没有匹配到支持的意图')
return this.sendHubJson(response, 202, { status: 'ignored', reason: 'no matching intent' })
}
if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' })
const items = listRecentChat(recentChatLimit)
const lines = items.map((item, index) => {
const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
return `${index + 1}. ${name}(${item.type === 'group' ? '群聊' : '联系人'})`
})
const reply = lines.length
? `最近 ${items.length} 个会话:\n${lines.join('\n')}`
: '暂时没有找到最近会话。'
try {
await this.sendConnector(inbound, reply)
} catch (error) {
this.addLog('agent-hub', 'error', `回复发送失败:${this.errorMessage(error)}`)
return this.sendHubJson(response, 502, { error: 'reply delivery failed' })
if (!text.trim()) {
this.addLog('agent-hub', 'info', '消息已忽略:内容为空')
return this.sendHubJson(response, 202, { status: 'ignored', reason: 'empty text' })
}
if (messageId) this.processedMessages.set(messageId, Date.now())
this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`)
this.sendHubJson(response, 200, { status: 'ok' })
void this.handleKnowledgeQuery(inbound, text)
return this.sendHubJson(response, 202, { status: 'processing', mode: 'query-agent' })
}
private async handleNaturalLanguage(inbound: InboundMessage, text: string): Promise<void> {
try {
const result = await this.resolveNaturalLanguage(text)
if (result.reply) {
await this.sendConnector(inbound, this.formatAIReply(result.reply))
this.addLog('agent-hub', 'info', '自然语言回复已发送')
return
}
if (!result.command) {
await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。')
return
}
this.addLog('agent-hub', 'info', `自然语言已理解为:${result.command}`)
const reportIntent = this.matchGroupReportIntent(result.command)
if (reportIntent) {
await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch(() => undefined)
await this.generateAndSendReport(inbound, reportIntent)
return
}
const groupMemberIntent = this.matchGroupMemberChatIntent(result.command)
if (groupMemberIntent) {
await this.summarizeGroupMemberChat(inbound, groupMemberIntent)
return
}
const contactIntent = this.matchContactChatIntent(result.command)
if (contactIntent) {
if (contactIntent.summarize) await this.summarizeContactChat(inbound, contactIntent)
else await this.replyContactChat(inbound, contactIntent)
return
}
const recentLimit = this.matchRecentChatIntent(result.command)
if (recentLimit !== null) {
await this.replyRecentChats(inbound, recentLimit)
return
}
await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。')
} catch (error) {
this.addLog('agent-hub', 'error', `自然语言处理失败:${this.errorMessage(error)}`)
await this.sendConnector(inbound, `处理失败:${this.errorMessage(error)}`).catch(
() => undefined
)
}
}
private async replyRecentChats(inbound: InboundMessage, limit: number): Promise<void> {
if (!isReady()) {
await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。')
/**
* 查询类问题("微信里发生了什么"、普通闲聊)统一走 Query Agent Runtime。
* 失败时不回退 Report Action,只给用户明确文案。
*/
private async handleKnowledgeQuery(inbound: InboundMessage, text: string): Promise<void> {
const service = this.queryAgent
if (!service) {
this.addLog('agent-hub', 'error', '查询大脑尚未初始化')
await this.sendConnector(inbound, QUERY_AGENT_UNAVAILABLE_TEXT).catch(() => undefined)
return
}
const items = listRecentChat(limit)
const lines = items.map((item, index) => {
const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
return `${index + 1}. ${name}(${item.type === 'group' ? '群聊' : '联系人'})`
})
await this.sendConnector(
inbound,
lines.length ? `最近 ${items.length} 个会话:\n${lines.join('\n')}` : '暂时没有找到最近会话。'
)
this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`)
}
private async resolveNaturalLanguage(text: string): Promise<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 {
const json = result.data.match(/\{[\s\S]*\}/)?.[0]
if (!json) return {}
const parsed = JSON.parse(json) as Record<string, unknown>
const limit = Math.max(1, Math.min(20, Number(parsed['limit']) || 5))
if (parsed['type'] === 'recent') return { command: `最近${limit}条消息` }
if (parsed['type'] === 'contact' && String(parsed['contact'] || '').trim()) {
return { command: `我和${String(parsed['contact']).trim()}最近${limit}条聊了什么` }
}
if (parsed['type'] === 'report' && String(parsed['group'] || '').trim()) {
const range =
parsed['range'] === '7days'
? '最近7天'
: parsed['range'] === 'yesterday'
? '昨天'
: '今天'
return { command: `生成${String(parsed['group']).trim()}${range}的群聊总结图片` }
}
if (
parsed['type'] === 'group_member' &&
String(parsed['group'] || '').trim() &&
String(parsed['member'] || '').trim()
) {
const range =
parsed['range'] === 'yesterday'
? '昨天'
: parsed['range'] === 'today'
? '今天'
: '最近7天'
const group = String(parsed['group'] || '')
.trim()
.replace(/(?:群聊|群)+$/g, '')
const days = Math.max(1, Math.min(30, Number(parsed['days']) || 7))
const goal = String(parsed['goal'] || '总结发言').trim()
return {
command: `看看${group}群里${String(parsed['member']).trim()}${range === '最近7天' ? `最近${days}天` : range}说了什么,${goal}`
}
}
if (parsed['type'] === 'chat') {
const reply = String(parsed['reply'] || '').trim()
return reply ? { reply: reply.slice(0, 1500) } : {}
}
const conversationKey = `${String(inbound.account_id || '')}::${String(inbound.from_user_id || '')}`
const result = await service.ask(
{ requestId: `agent-hub-${Date.now()}-${this.nextLogId}`, text },
conversationKey
)
await this.sendConnector(inbound, this.formatAIReply(queryAgentReplyText(result)))
this.addLog('agent-hub', 'info', `查询回答已发送(${result.status})`)
} catch (error) {
this.addLog('agent-hub', 'warn', `自然语言结果解析失败:${this.errorMessage(error)}`)
}
return {}
}
private async replyContactChat(
inbound: InboundMessage,
intent: ContactChatIntent
): Promise<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
)
this.addLog('agent-hub', 'error', `查询处理失败:${this.errorMessage(error)}`)
await this.sendConnector(inbound, QUERY_AGENT_UNAVAILABLE_TEXT).catch(() => undefined)
}
}
@@ -881,65 +665,6 @@ export class AgentHubService {
return { ok: response.ok, status: response.status, body: await response.text() }
}
private matchRecentChatIntent(text: string): number | null {
const normalized = text.replace(/\s+/g, '')
if (!normalized.includes('最近') || !/(消息|会话|聊天)/.test(normalized)) return null
const limit = Number(normalized.match(/\d{1,2}/)?.[0] || 5)
return Math.max(1, Math.min(20, limit))
}
private matchContactChatIntent(text: string): ContactChatIntent | null {
const normalized = text.replace(/\s+/g, '').replace(/[,。!??::]/g, '')
if (!normalized.includes('最近') || !/(聊|消息|会话)/.test(normalized)) return null
const patterns = [
/(?:看一下|看看|查一下|查询)?我和(.+?)最近(?:\d{1,2}条)?(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/,
/(?:看一下|看看|查一下|查询)?(?:我)?最近(?:\d{1,2}条)?和(.+?)(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/,
/(?:看一下|看看|查一下|查询)?和(.+?)最近(?:\d{1,2}条)?(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/
]
const contact = patterns
.map((pattern) => normalized.match(pattern)?.[1]?.trim())
.find((value): value is string => Boolean(value))
if (!contact) return null
const limit = Number(normalized.match(/最近(\d{1,2})条/)?.[1] || 10)
const summarize = /(聊了什么|聊什么|说了什么|谈了什么|总结)/.test(normalized)
return { contact, limit: Math.max(1, Math.min(20, limit)), summarize }
}
private matchGroupMemberChatIntent(text: string): GroupMemberChatIntent | null {
const normalized = text.trim().replace(/[,。!??::]/g, '')
const timePattern = '(今天|今日|昨天|昨日|最近\\d{1,2}天|近\\d{1,2}天|最近|近来|这几天)'
const actionPattern = '(?:说了什么|聊了什么|发言|说过什么|都聊什么|都说什么|干了什么)'
const patterns = [
new RegExp(
`(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)[\\s,,]+(.+?)${timePattern}${actionPattern}`
),
new RegExp(
`(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)(?:里|中的)(.+?)${timePattern}${actionPattern}`
)
]
for (const pattern of patterns) {
const match = normalized.match(pattern)
if (match?.[1]?.trim() && match[2]?.trim()) {
const range = /昨天|昨日/.test(match[3] || '')
? 'yesterday'
: /今天|今日/.test(match[3] || '')
? 'today'
: '7days'
const days = Math.max(1, Math.min(30, Number((match[3] || '').match(/\d{1,2}/)?.[0]) || 7))
return {
group: match[1].trim(),
member: match[2].trim(),
range,
days,
goal: normalized
}
}
}
return null
}
private resolveGroup(query: string): ReturnType<typeof resolveMd5> {
const normalize = (value: string): string =>
value
@@ -961,27 +686,6 @@ export class AgentHubService {
)
}
private matchGroupReportIntent(text: string): GroupReportIntent | null {
const normalized = text.trim()
if (!normalized.includes('群') || !/(总结|日报|报告)/.test(normalized)) return null
const range = /(7天|七天|一周)/.test(normalized)
? '7days'
: /(昨天|昨日)/.test(normalized)
? 'yesterday'
: 'today'
const group = normalized
.replace(
/请|帮我|生成|做一份|做个|今天的|今日的|今天|今日|昨天的|昨日的|昨天|昨日|最近7天的|最近七天的|最近7天|最近七天|近7天的|近七天的|近7天|近七天|消息|聊天记录|聊天|群聊总结|群总结|群日报|群报告|总结|日报|报告|图片|长图/g,
''
)
.replace(/[,。!??::]/g, '')
.trim()
.replace(/成$/, '')
.replace(/群$/, '')
.trim()
return group ? { group, range } : null
}
private authorized(header: string | undefined): boolean {
if (!header?.startsWith('Bearer ')) return false
const expected = Buffer.from(this.inboundToken)
@@ -586,6 +586,9 @@ export class AiSearchPipelineService {
state: 'ready',
indexedMessageCount: 0,
indexedChunkCount: 0,
// Legacy 路径不消费 freshness 口径;显式写 null,避免与 Query Agent 的覆盖语义混淆。
indexLatestAt: null,
sourceLatestAt: null,
totalMessages: 0,
evidence: [],
timings: emptyKnowledgeSearchTimings()
@@ -1379,6 +1382,8 @@ export class AiSearchPipelineService {
state: 'unavailable',
indexedMessageCount: 0,
indexedChunkCount: 0,
indexLatestAt: null,
sourceLatestAt: null,
totalMessages: 0,
evidence: [],
timings: emptyKnowledgeSearchTimings()
+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
}
/** 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[] {
if (!dbRef) return []
@@ -700,6 +755,35 @@ export async function getGroupMemberIdsAsync(
return memberIds ? { roomId, memberIds } : null
}
/**
* Evidence sender enrichment 专用:只解析**请求到的** wxid 的显示名。
*
* **不**走 `getGroupSnapshotAsync` —— 后者会 materialize 整群成员并 hydrate 头像,
* 为拿 1~N 个名字付整群成本。返回结构故意与 `GroupSnapshot['members']` 一致,
* 这样调用方可以复用同一套显示名优先级规则,不会把「张三」退化成「wxid_xxx」。
* `avatar` 恒为 `''`:头像若将来需要,走 lazy UI 路径,不进入 Query Tool 成本。
*/
export async function getGroupMemberNamesAsync(
userMd5: string,
wxids: string[]
): Promise<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 {
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 type { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
import { inferAiSearchTimeRange } from '../../shared/ai-search'
import type { QueryCapabilitiesResponse, QueryMessage, QueryMessageType, QueryTimeRange, ResolvedTimeRange, QueryMessagesRequest, SearchMessagesRequest, MessageContextRequest, ConversationOverviewRequest } from '../../shared/local-query-api'
import { KNOWLEDGE_FRESHNESS_TOLERANCE_MS } from '../../shared/knowledge'
import type {
QueryCapabilitiesResponse,
QueryCorpusScope,
QueryEvidenceItem,
QueryMessage,
QueryMessageType,
QueryTimeRange,
ResolvedCorpusScope,
ResolvedTimeRange,
QueryIndexCoverage,
QuerySearchTimings,
QueryMessagesRequest,
SearchMessagesRequest,
MessageContextRequest,
ConversationOverviewRequest
} from '../../shared/local-query-api'
// messageRef 编解码是 main 与 renderer 共用的契约,只定义一次(`src/shared/local-query-api.ts`):
// 两侧各写一份 base64url 实现会悄悄漂移,那会让「跳转到原聊天」偶发失效。
import {
encodeMessageRef as toRef,
decodeMessageRef as fromRef,
normalizeMessageIdentity
} from '../../shared/local-query-api'
const LIMIT_MAX = 200
const CONTEXT_MAX = 50
/** 会话概览直读源数据时的上限(与派生索引的会话概览同量级)。 */
const OVERVIEW_SOURCE_CAP = 2000
/** 会话概览最多挑选多少条代表证据(与派生索引的候选上限一致)。 */
const OVERVIEW_EVIDENCE_TARGET = 60
const OVERVIEW_CHUNK_GAP_MS = 2 * 60 * 60 * 1000
const OVERVIEW_CHUNK_MAX_MESSAGES = 24
const kinds: QueryMessageType[] = ['text', 'image', 'voice', 'video', 'file', 'link', 'sticker', 'system', 'other']
/**
* 查询触发追赶同步后最多等待多久(`QUERY_FRESHNESS_WAIT_BUDGET`)。
*
* 一次完整 pass 的耗时以分钟计,远超任何交互预算。所以这个预算只用来兜住「已经很接近追平」
* 的情况:追不上就按当前覆盖如实回答并让后台继续追,而不是把查询卡在索引上。
*/
const QUERY_FRESHNESS_WAIT_BUDGET_MS = 2000
/**
* 两次由查询触发的追赶之间的最小间隔,避免每个 Query 都重跑一遍索引。
*/
const QUERY_CATCH_UP_MIN_INTERVAL_MS = 30 * 1000
/**
* 值得为它跑一遍索引的最小落后量(下限)。
*
* 追赶一遍的成本以分钟计,因此几秒钟/一两分钟的落后并不值得触发。真正的门槛是
* `max(这个下限, 上一遍实际耗时)` —— 见 `LocalQueryApiService.ensureFreshness`。
* 这样在"源数据一直在长"的情况下会自然收敛:一遍跑完后剩下的落后量约等于这一遍的耗时,
* 于是不会立刻再触发一遍(否则会变成永不停止的连续索引)。
*/
const QUERY_CATCH_UP_MIN_LAG_MS = 2 * 60 * 1000
/**
* 请求的时间范围是否已经被派生索引覆盖。
*
* 需要覆盖的真实边界是 `min(requestedEnd, sourceLatestAt)`:
* - 请求范围早于源数据最新时间 → 必须覆盖到 requestedEnd;
* - 请求范围延伸到"现在" → 覆盖到源数据最新就已经完整(其后本来没有内容)。
*
* freshness 与 coverage 共用这**一处**判据,避免两套口径漂移。
*/
function indexCovers(indexLatestAt: number | null, requestedEnd: number, sourceLatestAt: number | null): boolean {
if (indexLatestAt === null) return false
const requiredEnd = sourceLatestAt === null ? requestedEnd : Math.min(requestedEnd, sourceLatestAt)
return indexLatestAt + KNOWLEDGE_FRESHNESS_TOLERANCE_MS >= requiredEnd
}
/**
* `ResolvedTimeRange` 用的是 **epoch 秒**(Local Query API contract),
* 而 freshness 口径(indexLatestAt / sourceLatestAt)是 **epoch 毫秒**。
* 这里统一到毫秒,避免混单位把"落后"误判成"已覆盖"。
*/
function rangeEndMs(range: ResolvedTimeRange, fallbackNowMs: number): number {
return range.endTime === undefined ? fallbackNowMs : range.endTime * 1000
}
/** 本地时间(`MM-DD HH:mm`);只用于给模型一句可引用的人话,不参与任何判断。 */
function formatLocalMinute(ms: number): string {
const date = new Date(ms)
const pad = (value: number): string => String(value).padStart(2, '0')
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
/**
* 索引覆盖结论:模型直接引用,不要自己换算时间、也不要输出 epoch 数字。
* 落后时必须把"这段时间暂时无法确认"写进结论句,避免被读成"整段时间都没有"。
*/
function buildIndexCoverage(
indexLatestAt: number | null,
sourceLatestAt: number | null,
covered: boolean
): QueryIndexCoverage | undefined {
if (indexLatestAt === null) return undefined
const indexLabel = formatLocalMinute(indexLatestAt)
return {
covered,
indexLatestAtLabel: indexLabel,
...(sourceLatestAt !== null ? { sourceLatestAtLabel: formatLocalMinute(sourceLatestAt) } : {}),
summary: covered
? `可搜索索引已覆盖所问的时间范围(索引最新到 ${indexLabel})。`
: `可搜索索引只更新到 ${indexLabel},这之后的聊天还没进索引,这段时间是否聊过暂时无法确认。`
}
}
const KIND_LABELS: Record<QueryMessageType, string> = {
text: '文本',
image: '图片',
voice: '语音',
video: '视频',
file: '文件',
link: '链接',
sticker: '表情',
system: '系统消息',
other: '消息'
}
function kindOf(message: FormattedMessage): QueryMessageType {
if (message.contentData?.type === 'system') return 'system'
if (message.exportMediaType) return message.exportMediaType
@@ -21,32 +137,6 @@ function kindOf(message: FormattedMessage): QueryMessageType {
if (message.content?.trim()) return 'text'
return 'other'
}
interface CanonicalMessageIdentity {
conversationId: string
messageId: string
}
function normalizeMessageIdentity(conversationId: string, messageId: string): CanonicalMessageIdentity | null {
const normalizedConversationId = conversationId.trim()
const normalizedMessageId = messageId.trim().replace(/^local:/, '')
if (!normalizedConversationId || !normalizedMessageId) return null
return { conversationId: normalizedConversationId, messageId: normalizedMessageId }
}
function toRef(conversationId: string, messageId: string): string {
const identity = normalizeMessageIdentity(conversationId, messageId)
if (!identity) throw new Error('消息引用无效')
return Buffer.from(JSON.stringify({ c: identity.conversationId, m: identity.messageId }), 'utf8').toString('base64url')
}
function fromRef(value: string): CanonicalMessageIdentity | null {
try {
const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'))
return typeof parsed?.c === 'string' && typeof parsed?.m === 'string'
? normalizeMessageIdentity(parsed.c, parsed.m)
: null
} catch { return null }
}
function contactView(contact: FormattedContact) { return { displayName: contact.m_nsNickName || contact.m_nsUsrName, type: contact.type } as const }
function resolvedTimeRange(input: QueryTimeRange, now = new Date()): ResolvedTimeRange {
if (input.kind === 'absolute') {
@@ -76,54 +166,490 @@ function toQueryMessage(conversationId: string, message: FormattedMessage, targe
return { messageRef: toRef(conversationId, message.id), timestamp: (message.createTime || 0) * 1000, datetime: message.datetime, sender: message.isSender ? '我' : (message.name || target.m_nsNickName), direction: message.isSender ? 'to_target' : 'from_target', messageType: kind, sourceKind: kind, ...(attachment ? { attachment } : {}), ...(text ? { text } : {}) }
}
/**
* 单条证据的展示形态:群消息必须带**群名 + 发送者**,否则模型无法回答"谁聊过"。
* 不含 wxid / md5 / DB id;messageRef 保持 opaque。
*/
function toEvidenceItem(contact: FormattedContact, message: FormattedMessage): QueryEvidenceItem {
const view = toQueryMessage(contact.md5, message, contact)
const name = contactView(contact).displayName
return {
messageRef: view.messageRef,
timestamp: view.timestamp,
sender: view.sender,
sourceKind: view.sourceKind,
text: view.text || `[${KIND_LABELS[view.messageType]}]`,
conversationName: name,
conversationType: contact.type
}
}
/** 语料边界的解析结果。 */
interface ResolvedCorpus {
/** undefined = 不限会话(全部可读会话)。 */
conversationIds?: string[]
scope: ResolvedCorpusScope
/** contact / current 命中的会话。 */
contact?: FormattedContact
error?: { status: string; [key: string]: unknown }
}
function describeScope(scope: ResolvedCorpusScope): string {
if (scope.kind === 'all') return '所有聊天记录'
if (scope.kind === 'groups') return '群聊专属'
return `${scope.kind === 'contact' ? '单聊专属' : '当前会话'}:${scope.displayName || '未知会话'}`
}
/**
* 语料边界违规 → 返回**可修正**的 invalid_tool_arguments(Runtime 会重开该 Tool 让模型改)。
* 这是结构性约束:模型无法用 prompt 绕过。
*/
function outsideScopeError(scope: ResolvedCorpusScope, actual: string): { status: string; [key: string]: unknown } {
return {
status: 'invalid_tool_arguments',
field: 'target',
constraint: 'target_outside_scope',
expected: describeScope(scope),
actual,
hint: 'target 必须落在应用当前的搜索范围内。需要查其他会话时,请让用户切换搜索范围;不要自行扩大范围。'
}
}
export class LocalQueryApiService {
constructor(private readonly knowledge?: KnowledgeSearchService, private readonly nowProvider: () => Date = () => new Date()) {}
capabilities(): QueryCapabilitiesResponse {
return { version: 1, tools: { query_messages: { operation: '读取指定联系人的确定性消息', directions: ['any', 'from_target', 'to_target'], messageTypes: kinds, timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX }, search_messages: { operation: '受限 Knowledge 关键词检索', timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX }, message_context: { operation: '读取消息前后文', timeRanges: ['all'], limitMax: CONTEXT_MAX }, conversation_overview: { operation: '按会话时间片提取概览证据', timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX } } }
}
private async resolve(target: { query: string }) {
const contacts = await listContactsAsync()
const result = resolveContact(target.query, contacts)
return { contacts, result }
/**
* 解析语料边界。`scope` 由调用方(UI / Host)提供;省略 = 不限。
* 这里只做**确定性**展开(群列表 / 会话身份校验),不做任何语义推断。
*/
private async resolveCorpus(scope: QueryCorpusScope | undefined, contacts: FormattedContact[]): Promise<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) {
if (!request?.target?.query?.trim() || !request.timeRange || !['any', 'from_target', 'to_target'].includes(request.direction || 'any') || (request.messageTypes || []).some((type) => !kinds.includes(type))) return { status: 'invalid_request' as const }
if (!request?.timeRange || !['any', 'from_target', 'to_target'].includes(request.direction || 'any') || (request.messageTypes || []).some((type) => !kinds.includes(type))) return { status: 'invalid_request' as const }
if (!isReady()) return { status: 'knowledge_unavailable' as const }
const { contacts, result } = await this.resolve(request.target)
if (!result.matched || !result.conversationId) return { status: result.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: result.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) }
const contact = contacts.find((c) => c.md5 === result.conversationId)!; if (contact.type === 'group' && request.direction && request.direction !== 'any') return { status: 'unsupported_query' as const }; const range = resolvedTimeRange(request.timeRange, this.nowProvider())
const contacts = await listContactsAsync()
const corpus = await this.resolveCorpus(request.scope, contacts)
if (corpus.error) return corpus.error as { status: string }
const targetQuery = request.target?.query?.trim()
let contact: FormattedContact | undefined
if (targetQuery) {
const result = resolveContact(targetQuery, contacts)
if (!result.matched || !result.conversationId) return { status: result.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: result.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) }
contact = contacts.find((c) => c.md5 === result.conversationId)!
if (corpus.conversationIds && !corpus.conversationIds.includes(contact.md5)) {
return outsideScopeError(corpus.scope, contactView(contact).displayName) as { status: string }
}
} else if (corpus.contact) {
// 省略 target:范围恰好只有一个会话(单聊专属 / 当前会话)时直接查它,避免让模型重新拼会话名。
contact = corpus.contact
} else if (corpus.conversationIds && corpus.conversationIds.length === 1) {
contact = contacts.find((c) => c.md5 === corpus.conversationIds![0])
}
if (!contact) {
return {
status: 'invalid_tool_arguments',
field: 'target',
constraint: 'target_required_for_scope',
expected: describeScope(corpus.scope),
actual: '省略 target',
hint: '当前搜索范围包含多个会话。精确读取消息必须指定 target(必须在该范围内),或改用 search_messages 做跨会话检索。'
}
}
if (contact.type === 'group' && request.direction && request.direction !== 'any') return { status: 'unsupported_query' as const }; const range = resolvedTimeRange(request.timeRange, this.nowProvider())
const raw = await listMessagesAsync(contact.md5, range.startTime, range.endTime)
const direction = request.direction || 'any'; const allowed = new Set(request.messageTypes || kinds)
const filtered = raw.filter((message) => !(request.excludeSystem !== false && kindOf(message) === 'system')).filter((message) => allowed.has(kindOf(message))).filter((message) => direction === 'any' || (direction === 'to_target' ? message.isSender : !message.isSender)).sort((a, b) => ((a.createTime || 0) - (b.createTime || 0)) * ((request.order || 'asc') === 'asc' ? 1 : -1)).slice(0, Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)))
return { status: 'completed' as const, target: contactView(contact), query: { direction, messageTypes: request.messageTypes || [], order: request.order || 'asc', limit: Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)), excludeSystem: request.excludeSystem !== false, resolvedTimeRange: range }, coverage: { state: 'complete' as const }, returnedCount: filtered.length, messages: filtered.map((message) => toQueryMessage(contact.md5, message, contact)) }
return { status: 'completed' as const, target: contactView(contact), query: { direction, messageTypes: request.messageTypes || [], order: request.order || 'asc', limit: Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)), excludeSystem: request.excludeSystem !== false, resolvedTimeRange: range }, coverage: { state: 'complete' as const }, returnedCount: filtered.length, messages: filtered.map((message) => toQueryMessage(contact.md5, message, contact)), scope: corpus.scope }
}
async search(request: SearchMessagesRequest) {
if (!request?.target?.query?.trim() || !request.timeRange || typeof request.query !== 'string' || !request.query.trim() || (request.variants || []).some((value) => typeof value !== 'string')) return { status: 'invalid_request' as const }
const { contacts, result } = await this.resolve(request.target)
if (!result.matched || !result.conversationId) return { status: result.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: result.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) }
const requestStartedAt = Date.now()
if (!request?.timeRange || typeof request.query !== 'string' || !request.query.trim() || (request.variants || []).some((value) => typeof value !== 'string')) return { status: 'invalid_request' as const }
// 真实耗时分解:每一段都用 Date.now() 实测,不做任何推断。
const scopeStartedAt = Date.now()
const contacts = await listContactsAsync()
const corpus = await this.resolveCorpus(request.scope, contacts)
if (corpus.error) return corpus.error as { status: string }
let conversationIds = corpus.conversationIds
let targetView: { displayName: string; type: 'user' | 'group' } | undefined
const targetQuery = request.target?.query?.trim()
if (targetQuery) {
const resolved = resolveContact(targetQuery, contacts)
if (!resolved.matched || !resolved.conversationId) {
return { status: resolved.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: resolved.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) }
}
const target = contacts.find((c) => c.md5 === resolved.conversationId)!
if (corpus.conversationIds && !corpus.conversationIds.includes(target.md5)) {
return outsideScopeError(corpus.scope, contactView(target).displayName) as { status: string }
}
conversationIds = [target.md5]
targetView = contactView(target)
} else if (corpus.conversationIds && corpus.conversationIds.length === 0) {
// 范围内没有任何会话(例如没有任何群聊):明确返回空,而不是悄悄退化成全局搜索。
return { status: 'completed' as const, coverage: { state: 'unknown' as const }, probeCount: 0, evidenceCount: 0, evidence: [], scope: corpus.scope }
}
const scopeMs = Date.now() - scopeStartedAt
if (!this.knowledge) return { status: 'knowledge_unavailable' as const }
const contact = contacts.find((c) => c.md5 === result.conversationId)!; const range = resolvedTimeRange(request.timeRange, this.nowProvider()); const probes = [request.query, ...(request.variants || [])]
const range = resolvedTimeRange(request.timeRange, this.nowProvider()); const probes = [request.query, ...(request.variants || [])]
if (probes.length > 5) return { status: 'invalid_request' as const }
const all = new Map<string, any>(); let coverage: 'complete' | 'partial' | 'unknown' = 'unknown'
for (const probe of probes) { const found = await this.knowledge.search({ text: probe, terms: [probe], conversationIds: [contact.md5], startTime: range.startTime, endTime: range.endTime, limit: Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)) }); coverage = found.state === 'ready' ? 'complete' : found.evidence.length ? 'partial' : 'unknown'; for (const item of found.evidence) all.set(`${item.conversationId}:${item.messageId}`, { messageRef: toRef(item.conversationId, item.messageId), messageId: item.messageId, timestamp: item.timestamp, sender: item.sender, sourceKind: item.sourceKind, text: item.text }) }
return { status: 'completed' as const, target: contactView(contact), resolvedTimeRange: range, coverage: { state: coverage }, probeCount: probes.length, evidenceCount: all.size, evidence: Array.from(all.values()).map(({ messageId: _messageId, ...item }) => item).slice(0, request.limit || 20) }
const contactByMd5 = new Map(contacts.map((contact) => [contact.md5, contact]))
const limit = Math.min(LIMIT_MAX, Math.max(1, request.limit || 20))
// 一次查询内多个 probe 共用同一个 retrieval session:Knowledge 会按它缓存
// "群会话 → 成员昵称" 的解析结果,否则每个 probe 都要重读一遍群成员快照,
// 跨会话检索会被放大成 N 倍。
const retrievalSessionId = `query-${this.nowProvider().getTime()}-${Math.random().toString(36).slice(2, 8)}`
// Freshness:请求的时间范围越过索引覆盖时,先请求既有增量通道去追一次;
// 这属于 Engine/Host 的确定性处理,**不增加 LLM 往返**。
//
// 整段交互检索期间必须让后台索引让路:追赶同步会遍历上千个会话,
// 否则本次查询会和它抢 Worker 与 WCDB,被拖成几十秒。
this.knowledge.beginInteractiveQuery()
const probeMs: number[] = []
let mergeMs = 0
let enrichmentMs = 0
let knowledgeTiming: NonNullable<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) {
const ref = fromRef(request.messageRef); if (!ref) return { status: 'invalid_request' as const }
if (request.scope && request.scope.kind !== 'all') {
const contacts = await listContactsAsync()
const corpus = await this.resolveCorpus(request.scope, contacts)
if (corpus.error) return corpus.error as { status: string }
if (corpus.conversationIds && !corpus.conversationIds.includes(ref.conversationId)) {
return outsideScopeError(corpus.scope, '其他会话的消息') as { status: string }
}
}
const before = Math.min(CONTEXT_MAX, Math.max(0, request.before ?? 10)); const after = Math.min(CONTEXT_MAX, Math.max(0, request.after ?? 10)); const messages = await listMessagesAsync(ref.conversationId); const index = messages.findIndex((message) => normalizeMessageIdentity(ref.conversationId, message.id)?.messageId === ref.messageId); if (index < 0) return { status: 'contact_not_found' as const }
const contact = (await listContactsAsync()).find((item) => item.md5 === ref.conversationId); if (!contact) return { status: 'contact_not_found' as const }; const map = (message: FormattedMessage) => toQueryMessage(ref.conversationId, message, contact)
return { status: 'completed' as const, anchor: map(messages[index]), before: messages.slice(Math.max(0, index - before), index).map(map), after: messages.slice(index + 1, index + 1 + after).map(map) }
}
/**
* 会话概览。
*
* **事实来源是 WCDB(源数据),不是派生 Knowledge 索引**:索引是异步派生的、可能滞后,
* 把"索引里 0 行"当成"完整范围内没有"会产生高置信度的错误否定。这里改为直读源数据
* 并显式区分 complete / partial。
*/
async overview(request: ConversationOverviewRequest) {
if (!request?.target?.query?.trim() || !request.timeRange) return { status: 'invalid_request' as const }
const { contacts, result } = await this.resolve(request.target); if (!result.matched || !result.conversationId) return { status: result.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: result.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) }; if (!this.knowledge) return { status: 'knowledge_unavailable' as const }
const contact = contacts.find((c) => c.md5 === result.conversationId)!; const range = resolvedTimeRange(request.timeRange, this.nowProvider()); const found = await this.knowledge.search({ text: '', terms: [], conversationIds: [contact.md5], startTime: range.startTime, endTime: range.endTime, limit: LIMIT_MAX }); const retrieval = found.conversationRetrieval
const sourceMessageCount = retrieval?.totalMessages || 0
const evidence = found.evidence
.map((item, index) => ({ messageRef: toRef(item.conversationId, item.messageId), timestamp: item.timestamp, sender: item.sender, sourceKind: item.sourceKind, text: item.text, index }))
.sort((left, right) => left.timestamp - right.timestamp || left.index - right.index)
.map(({ index: _index, ...item }) => item)
const sourceState = retrieval?.complete ? 'complete' as const : 'partial' as const
return { status: found.state === 'ready' ? 'completed' as const : 'retrieval_incomplete' as const, target: contactView(contact), resolvedTimeRange: range, coverage: { state: sourceState }, sourceMessageCount, evidenceCount: evidence.length, sourceCoverage: { state: sourceState, sourceMessageCount }, selection: { mode: 'temporal_coverage' as const, selectedEvidenceCount: evidence.length, sampled: evidence.length < sourceMessageCount }, voiceCoverage: found.voiceCoverage, evidence }
if (!request?.timeRange) return { status: 'invalid_request' as const }
if (!isReady()) return { status: 'knowledge_unavailable' as const }
const contacts = await listContactsAsync()
const corpus = await this.resolveCorpus(request.scope, contacts)
if (corpus.error) return corpus.error as { status: string }
const targetQuery = request.target?.query?.trim()
let contact: FormattedContact | undefined
if (targetQuery) {
const resolved = resolveContact(targetQuery, contacts)
if (!resolved.matched || !resolved.conversationId) {
return { status: resolved.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: resolved.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) }
}
contact = contacts.find((c) => c.md5 === resolved.conversationId)!
if (corpus.conversationIds && !corpus.conversationIds.includes(contact.md5)) {
return outsideScopeError(corpus.scope, contactView(contact).displayName) as { status: string }
}
} else if (corpus.contact) {
contact = corpus.contact
} else if (corpus.conversationIds && corpus.conversationIds.length === 1) {
contact = contacts.find((c) => c.md5 === corpus.conversationIds![0])
}
if (!contact) {
return {
status: 'invalid_tool_arguments',
field: 'target',
constraint: 'target_required_for_scope',
expected: describeScope(corpus.scope),
actual: '省略 target',
hint: '当前搜索范围包含多个会话,会话概览只能针对单个会话。请显式指定 target(必须在该范围内),或改用 search_messages。'
}
}
const range = resolvedTimeRange(request.timeRange, this.nowProvider())
// 注意:这里**不能**给 listMessagesAsync 传 limit —— 实测在有界时间范围下
// `{ limit }` 会让 WCDB 读取返回 0 条(而同一范围不传 limit 能正常返回)。
// 与 query_messages 保持一致:读完整区间,再在 JS 侧截断/采样。
const raw = await listMessagesAsync(contact.md5, range.startTime, range.endTime)
const messages = raw.length > OVERVIEW_SOURCE_CAP ? raw.slice(-OVERVIEW_SOURCE_CAP) : raw
const truncated = raw.length > OVERVIEW_SOURCE_CAP
const evidence = selectTemporalCoverageEvidence(contact, messages, OVERVIEW_EVIDENCE_TARGET)
const state: 'complete' | 'partial' = truncated ? 'partial' : 'complete'
return {
status: 'completed' as const,
target: contactView(contact),
resolvedTimeRange: range,
coverage: { state },
sourceMessageCount: raw.length,
evidenceCount: evidence.length,
sourceCoverage: { state, sourceMessageCount: raw.length },
selection: { mode: 'temporal_coverage' as const, selectedEvidenceCount: evidence.length, sampled: truncated || evidence.length < messages.length },
evidence,
scope: corpus.scope,
origin: 'wcdb' as const
}
}
}
/**
* 时间片代表证据:按时间间隔切块,每块取"最长文本 / 首条 / 末条",再轮转挑选,
* 保证每个时间片都至少有一条代表,避免长会话里最近的时间片被整体丢弃。
*/
export function selectTemporalCoverageEvidence(
contact: FormattedContact,
messages: FormattedMessage[],
target: number
): QueryEvidenceItem[] {
if (!messages.length || target <= 0) return []
const chunks: FormattedMessage[][] = []
for (const message of messages) {
const current = chunks.at(-1)
const previous = current?.at(-1)
const gapMs = ((message.createTime || 0) - (previous?.createTime || 0)) * 1000
const isNewChunk = !current || gapMs > OVERVIEW_CHUNK_GAP_MS || current.length >= OVERVIEW_CHUNK_MAX_MESSAGES
if (isNewChunk) chunks.push([])
chunks.at(-1)!.push(message)
}
const representativesByChunk = chunks.map((chunk) => {
const preferred = chunk.filter((message) => kindOf(message) !== 'system')
const pool = preferred.length ? preferred : chunk
const ranked = [...pool].sort(
(left, right) =>
String(right.content || '').length - String(left.content || '').length ||
(right.createTime || 0) - (left.createTime || 0)
)
return [ranked[0], pool[0], pool.at(-1)].filter(
(value, index, items): value is FormattedMessage => Boolean(value) && items.indexOf(value) === index
)
})
const selected: FormattedMessage[] = []
for (let representativeIndex = 0; selected.length < target; representativeIndex += 1) {
let added = false
for (const representatives of representativesByChunk) {
const representative = representatives[representativeIndex]
if (representative && selected.length < target) {
selected.push(representative)
added = true
}
}
if (!added) break
}
return selected.map((message) => toEvidenceItem(contact, message))
}
@@ -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 { LOCAL_QUERY_TOOL_DEFINITIONS, type QueryTemporalBasisKind } from '../../shared/local-query-api'
import {
LOCAL_QUERY_TOOL_DEFINITIONS,
type QueryCorpusScope,
type QuerySearchTimings,
type QueryTemporalBasisKind
} from '../../shared/local-query-api'
// 进度事件定义在 shared(renderer 也要用),这里只是把它带进本文件作用域。
import type {
QueryAgentProgressEvent,
QueryAgentProgressStage
} from '../../shared/query-agent'
const MAX_TOOL_CALLS = 5
const FORBIDDEN_INPUT_KEYS = new Set(['apiKey', 'authorization', 'token', 'databasePath', 'sql', 'wxid', 'md5'])
@@ -70,6 +86,8 @@ export interface QueryAgentTraceItem {
status: string
resultCount?: number
evidenceCount?: number
/** 会话概览覆盖的源消息条数(additive,用于 UI 顶部真实统计)。 */
sourceMessageCount?: number
/** LLM 声明的 temporalBasis。Host 消费它决定 policy,但不会传给 Local Query API。 */
temporalBasis?: { kind: QueryTemporalBasisKind; sourceText?: string }
/** Host 自动执行的扩大查询(当前仅 temporalBasis.kind=recall_hint + 有界范围 + 0 结果)。 */
@@ -81,6 +99,13 @@ export interface QueryAgentTraceItem {
resultCount?: number
evidenceCount?: number
}
/**
* Engine 侧的真实耗时分解(ADDITIVE 诊断)。
*
* 由 `search_messages` 的 Tool Result 携带,**不会进入模型上下文**(`toolResultForModel`
* 会剥离)。用途:把不透明的 Tool 总耗时拆成 scope / freshness / 每个 probe / 合并 / 证据补全。
*/
searchTimings?: QuerySearchTimings
}
export interface QueryAgentModelCallDiagnostic {
@@ -95,7 +120,24 @@ export interface QueryAgentModelCallDiagnostic {
error?: string
}
export interface QueryAgentPocResult {
/**
* 失败分类(additive 诊断字段,供 Adapter 决定展示与是否允许 Legacy fallback)。
*
* 'provider_unavailable' = Provider 未配置;'provider_failure' = 模型请求本身失败
* (网络 / 上游 / 超时)。未分类的异常由 Adapter 归类为 runtime_error。
*/
export type QueryAgentErrorKind = 'invalid_question' | 'provider_unavailable' | 'provider_failure' | 'tool_limit'
/**
* 多轮澄清所需的最小历史。**由 Adapter 提供**,Runtime 只负责按顺序放进 messages。
* Runtime 自身仍然是无状态单轮执行器;历史长度 / 保留时间的边界由调用方负责。
*/
export interface QueryAgentHistoryTurn {
question: string
answer: string
}
export interface QueryAgentResult {
question: string
provider: string
model: string
@@ -112,13 +154,54 @@ export interface QueryAgentPocResult {
traces: QueryAgentTraceItem[]
answer?: string
error?: string
/** 失败分类;additive 诊断字段,成功时为 undefined */
errorKind?: QueryAgentErrorKind
/** 本次回答实际依据的证据(additive);按首次命中顺序去重。 */
evidence?: QueryAgentEvidenceItem[]
}
/**
* 语料边界(conversation scope):由 UI / Adapter 传入,**不是** LLM 的输入。
* `label` 只用于给模型描述"当前范围是什么",强制逻辑完全在 Engine(target 越界会被拒绝)。
*/
export interface QueryAgentConversationScope {
scope: QueryCorpusScope
label?: string
}
/** 单次 Tool 执行的上下文(由 Runtime 注入,LLM 无法提供)。 */
export interface QueryAgentToolContext {
conversationScope?: QueryCorpusScope
}
export type QueryAgentToolExecutor = (
name: string,
input: Record<string, unknown>
input: Record<string, unknown>,
context?: QueryAgentToolContext
) => 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。
规划原则:
@@ -131,6 +214,11 @@ const SYSTEM_PROMPT = `你是 TraceMemo 的本地聊天查询助手,只能使
- 普通聊天查询不是 exhaustive investigation。经过合理的检索或可选 context 仍不足以形成强结论时,直接说明证据范围和不确定性,不要循环调用 search、overview、context。
事实边界:不得编造未返回的消息、猜测联系人、修改 resolvedTimeRange,或把 partial/unknown 当作 complete。coverage complete 且结果为 0 时,可以说明当前可读取的完整范围没有找到;coverage partial/unknown 且结果为 0 时,必须说明无法确认绝对不存在。不要把 sampled Evidence 当作完整聊天,也不要把 source message count 和 selected evidence count 混为一谈。
索引新鲜度:search_messages 的 indexLatestAt / sourceLatestAt 是**结构化事实**,indexCoverage 是 Engine 给出的结论句。规则:
- 覆盖边界只能引用 indexCoverage(含本地时间与结论),**不要自己换算时间,也不要把 epoch 数字写进回答**。
- indexCoverage.covered 为 false 时,说明这段时间还没进索引:此时即使结果为 0 也只能说"索引尚未覆盖这段时间,暂时无法确认",**绝不能**说成"没有"。必须如实引用结论里的索引更新时间。
- 已经检索到 Evidence 时,只有当这个覆盖边界真的会影响结论时才补一句说明,不要机械附加警告。
- 只有 coverage.state 为 complete(indexCoverage.covered 为 true)且结果为 0,才可以下"没有找到"的结论。不要自己把 partial 说成 complete。
缺少必要信息时用自然语言澄清;超出工具能力时说明不能可靠完成,并给出当前工具可以执行的替代方向。`
function toolDefinitions(): AIChatToolDefinition[] {
@@ -299,12 +387,10 @@ interface CanonicalTemporalBasis {
}
/**
* LLM Tool Adapter 的 canonicalization:
* - temporalBasis 是 LLM-facing 元数据:Host 用它决定 policy,但**剥离**后不传给 Local Query API
* - absolute 的 ISO-8601 → Local Query API 的 epoch seconds
* - search_messages 的 queries[] → Local Query API 的 query + variants
* LLM Tool Adapter 的 canonicalization:剥离 LLM-facing 元数据(temporalBasis)、把 absolute 的
* ISO-8601 换成 Local Query API 的 epoch seconds、把 `queries[]` 摊平成 query + variants。
*
* 这里只做**纯 lexical** 校验(sourceText 是否为用户问题子串 + kind 与 timeRange 是否自洽),
* 这里只做**纯 lexical** 校验(sourceText 是否为用户问题子串、kind 与 timeRange 是否自洽),
* 不解释时间短语的意思。
*/
function canonicalizeToolInput(
@@ -482,10 +568,12 @@ export function validateToolArguments(
return canonicalizeToolInput(name, value as Record<string, unknown>, now, question)
}
function resultCount(result: QueryAgentToolResult): { resultCount?: number; evidenceCount?: number } {
function resultCount(result: QueryAgentToolResult): { resultCount?: number; evidenceCount?: number; sourceMessageCount?: number } {
return {
resultCount: typeof result.returnedCount === 'number' ? result.returnedCount : undefined,
evidenceCount: typeof result.evidenceCount === 'number' ? result.evidenceCount : Array.isArray(result.evidence) ? result.evidence.length : undefined
evidenceCount: typeof result.evidenceCount === 'number' ? result.evidenceCount : Array.isArray(result.evidence) ? result.evidence.length : undefined,
// 会话概览用它说明"覆盖了多少条源消息",UI 顶部统计需要真实数字。
sourceMessageCount: typeof result.sourceMessageCount === 'number' ? result.sourceMessageCount : undefined
}
}
@@ -551,31 +639,220 @@ function nextToolDefinitions(name: string, result: QueryAgentToolResult, state:
return []
}
export class QueryAgentPocService {
/** 进度阶段的类型定义在 `src/shared/query-agent.ts`(renderer 也要消费它,放这里会让 renderer 反向依赖 main)。 */
export type { QueryAgentProgressEvent, QueryAgentProgressStage } from '../../shared/query-agent'
export interface QueryAgentRunOptions {
/**
* 最小多轮澄清支持:前几轮(问 + 答)的问答对,由 Adapter 负责长度 / 时间 / 隐私边界。
* 不传时 messages = [system, user]。
*/
history?: QueryAgentHistoryTurn[]
/**
* 语料边界(搜索范围)。由 UI 决定;Runtime 负责把它传给 Engine 并在 prompt 里说明,
* 但**强制**发生在 Engine(target 越界 → 可修正的 invalid_tool_arguments)。
* 不传则不限制范围、不加范围说明。
*/
conversationScope?: QueryAgentConversationScope
/**
* 真实进度回调(ADDITIVE)。由 Adapter 转发给 UI;不传则零额外开销。
* 回调抛出的异常不会影响查询本身。
*/
onProgress?: (event: QueryAgentProgressEvent) => void
}
/** 给模型的范围说明:只描述边界,不做"请遵守"的祈祷式约束(约束由 Engine 强制)。 */
function conversationScopeNote(input: QueryAgentConversationScope): string {
const { scope } = input
const label = input.label?.trim()
const header = label ? `当前搜索范围(由应用界面决定):${label}。` : '当前搜索范围由应用界面决定。'
const rules = [
'所有工具调用都会被强制限制在这个范围内;target 若不在范围内会被拒绝,被拒绝时请如实说明范围限制,不要试图绕过。',
'范围之外还有别的会话,但你**看不到**它们,也不要在回答里声称它们的情况。'
]
if (scope.kind === 'groups') {
rules.push(
'范围是群聊专属:包含全部群会话以及群成员实际发送的消息。问"谁聊过某话题"时应省略 search_messages 的 target 做跨群检索,并在回答里保留群名与发送者。'
)
} else if (scope.kind === 'contact' || scope.kind === 'current') {
rules.push(
'范围只有一个会话:所有工具都可以省略 target(query_messages 也可以),省略即在该会话内检索;不要猜会话名,也不要指定其他会话。'
)
}
return `${header}\n${rules.map((rule) => `- ${rule}`).join('\n')}`
}
/**
* 证据收集器(Host 侧)。
*
* 从 Tool Result 中提取**真实**证据供 UI 展示 —— UI 不允许从回答文本里反解析证据。
* - 按 `messageRef` 去重(search 与 context 命中同一条消息只显示一次);
* - 顺序 = 首次命中顺序;上限 MAX_EVIDENCE_ITEMS;
* - 只保留展示字段,不携带 wxid / md5 / DB id / raw Tool JSON。
*/
class EvidenceCollector {
private readonly items = new Map<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(
private readonly provider: QueryAgentProvider,
private readonly executeTool: QueryAgentToolExecutor,
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 startedAt = Date.now()
const runtime = this.provider.getRuntimeConfig()
const result: QueryAgentPocResult = { question: trimmed, provider: runtime.providerName, model: runtime.modelName || runtime.model, modelCallCount: 0, toolCallCount: 0, toolTotalMs: 0, totalMs: 0, traces: [], modelDurationsMs: [], modelDiagnostics: [] }
if (!trimmed) return { ...result, error: '请输入查询问题', totalMs: Date.now() - startedAt }
if (!runtime.configured) return { ...result, error: '当前 AI Provider 尚未配置', totalMs: Date.now() - startedAt }
const result: QueryAgentResult = { question: trimmed, provider: runtime.providerName, model: runtime.modelName || runtime.model, modelCallCount: 0, toolCallCount: 0, toolTotalMs: 0, totalMs: 0, traces: [], modelDurationsMs: [], modelDiagnostics: [] }
if (!trimmed) return { ...result, error: '请输入查询问题', errorKind: 'invalid_question', totalMs: Date.now() - startedAt }
if (!runtime.configured) return { ...result, error: '当前 AI Provider 尚未配置', errorKind: 'provider_unavailable', totalMs: Date.now() - startedAt }
const history = options.history || []
const scopeNote = options.conversationScope ? conversationScopeNote(options.conversationScope) : undefined
const messages: Array<Record<string, unknown>> = [
{ role: 'system', content: SYSTEM_PROMPT },
// 范围说明是**上下文**,不是强制执行手段:真正的边界由 Engine 拒绝越界 target 来保证。
...(scopeNote ? [{ role: 'system', content: scopeNote }] : []),
...history.flatMap((turn) => [
{ role: 'user', content: turn.question },
{ role: 'assistant', content: turn.answer }
]),
{ role: 'user', content: trimmed }
]
const toolContext: QueryAgentToolContext = options.conversationScope
? { conversationScope: options.conversationScope.scope }
: {}
const evidence = new EvidenceCollector()
let tools = toolDefinitions()
const retry = newRetryState()
const now = this.nowProvider()
let firstModelAt: number | undefined
let finalModelDuration: number | undefined
while (result.toolCallCount < MAX_TOOL_CALLS) {
// 真实生命周期边界:还没有任何 Tool 结果 → 这次模型调用是"理解问题";
// 已经有结果 → 这次是在消化证据并**生成回答**。不用定时器、不猜进度。
emit(result.toolCallCount === 0 ? 'understanding' : 'generating_answer')
const modelStartedAt = Date.now()
const model = await this.provider.chatWithTools(messages, tools)
result.modelCallCount += 1
@@ -594,7 +871,7 @@ export class QueryAgentPocService {
...(model.success ? {} : { error: model.error || '模型调用失败' })
})
if (firstModelAt === undefined) firstModelAt = Date.now()
if (!model.success) return { ...result, error: model.error || '模型调用失败', firstModelMs: firstModelAt - startedAt, totalMs: Date.now() - startedAt }
if (!model.success) return { ...result, error: model.error || '模型调用失败', errorKind: 'provider_failure', firstModelMs: firstModelAt - startedAt, totalMs: Date.now() - startedAt }
const calls = model.toolCalls || []
if (calls.length === 0) {
finalModelDuration = modelDuration
@@ -605,7 +882,7 @@ export class QueryAgentPocService {
return result
}
if (result.toolCallCount + calls.length > MAX_TOOL_CALLS) {
return { ...result, error: `超过最大工具调用次数(${MAX_TOOL_CALLS})`, firstModelMs: firstModelAt - startedAt, totalMs: Date.now() - startedAt }
return { ...result, error: `超过最大工具调用次数(${MAX_TOOL_CALLS})`, errorKind: 'tool_limit', firstModelMs: firstModelAt - startedAt, totalMs: Date.now() - startedAt }
}
messages.push({ role: 'assistant', content: model.data || '', tool_calls: calls.map((call) => ({ id: call.id, type: 'function', function: { name: call.name, arguments: call.arguments } })) })
for (const call of calls) {
@@ -639,13 +916,17 @@ export class QueryAgentPocService {
toolResult = { status: 'invalid_tool_arguments', field: '$', constraint: 'duplicate_retry', expected: '与上一次实质不同的条件', actual: '与上一次完全相同的条件' }
} else {
recordAttempt(call.name, traceInput, retry)
const primary = await this.executeTool(call.name, traceInput)
// Tool 真正开始执行 = "在搜索聊天记录"。跨会话范围会明显更慢,
// UI 用范围(不是调用次数)决定副提示文案。
emit('searching', { toolName: call.name })
const primary = await this.executeTool(call.name, traceInput, toolContext)
toolResult = primary
// recall_hint + 有界范围 + 0 结果 → Host 自动做一次“全部历史”corrective lookup。
// 这是一次本地 Query API 调用:不增加 LLM 往返,也不占用 MAX_TOOL_CALLS。
// 注意:自动补查必须沿用同一个语料边界,不能借它逃出当前搜索范围。
if (shouldAutoBroaden(call.name, temporalBasis, traceInput, primary)) {
const fallbackStartedAt = Date.now()
const fallbackResult = await this.executeTool('query_messages', { ...traceInput, timeRange: { kind: 'all' } })
const fallbackResult = await this.executeTool('query_messages', { ...traceInput, timeRange: { kind: 'all' } }, toolContext)
const fallbackCounts = resultCount(fallbackResult)
autoFallback = { reason: AUTO_FALLBACK_REASON, timeRange: { kind: 'all' }, status: fallbackResult.status, durationMs: Date.now() - fallbackStartedAt, ...fallbackCounts }
toolResult = {
@@ -672,9 +953,21 @@ export class QueryAgentPocService {
const completedToolResult = toolResult || { status: 'invalid_request', error: '工具调用失败' }
const durationMs = Date.now() - inputStartedAt
result.toolCallCount += 1
counters.toolCallCount = result.toolCallCount
result.toolTotalMs += durationMs
// Tool Result 已经拿到 → 真实进入"整理证据"阶段。
emit('organizing_evidence', { toolName: call.name })
// 收集真实证据(去重、限量),供 UI 展示;不进入模型上下文。
evidence.addFromToolResult(call.name, completedToolResult)
result.evidence = evidence.list()
const counts = resultCount(completedToolResult)
result.traces.push({ toolName: call.name, input: sanitizeInput(traceInput), durationMs, status: completedToolResult.status, ...counts, ...(temporalBasis ? { temporalBasis } : {}), ...(autoFallback ? { autoFallback } : {}) })
// 引擎耗时分解留在 Host 侧(诊断 / UI),不进入模型上下文。
const rawTimings = completedToolResult.timings
const searchTimings: QuerySearchTimings | undefined =
rawTimings && typeof rawTimings === 'object' && !Array.isArray(rawTimings)
? (rawTimings as QuerySearchTimings)
: undefined
result.traces.push({ toolName: call.name, input: sanitizeInput(traceInput), durationMs, status: completedToolResult.status, ...counts, ...(temporalBasis ? { temporalBasis } : {}), ...(autoFallback ? { autoFallback } : {}), ...(searchTimings ? { searchTimings } : {}) })
const nextTools = completedToolResult.constraint === 'tool_availability'
? tools
: nextToolDefinitions(call.name, completedToolResult, retry, rangeKind(traceInput) === 'all')
@@ -685,6 +978,6 @@ export class QueryAgentPocService {
}
result.firstModelMs = firstModelAt ? firstModelAt - startedAt : undefined
result.totalMs = Date.now() - startedAt
return { ...result, error: `超过最大工具调用次数(${MAX_TOOL_CALLS})` }
return { ...result, error: `超过最大工具调用次数(${MAX_TOOL_CALLS})`, errorKind: 'tool_limit' }
}
}
+8 -1
View File
@@ -42,6 +42,12 @@ export interface AppSettings {
/** Keep a running personal-WeChat OneBot process across app restarts. */
keepPersonalWechatProcess?: boolean
windowsWechatPort: string
/**
* Query Agent 是否为桌面「问问微信」与 Agent Hub 查询类问题的主路径。
* 默认开启;关闭后回退到 Legacy AI Search Pipeline(仅作 runtime regression 时的回退开关,
* 不在用户界面暴露实验性名称)。
*/
queryAgentEnabled: boolean
}
function getDefaultDbRoot(): string {
@@ -125,7 +131,8 @@ const DEFAULT_SETTINGS: AppSettings = {
ttsSelectedVoiceId: '',
ttsModel: 's2.1-pro-free',
keepPersonalWechatProcess: false,
windowsWechatPort: ''
windowsWechatPort: '',
queryAgentEnabled: true
}
const SETTINGS_FILE = path.join(
+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 {
return Boolean(this.wcdbGetGroupMembersBatch)
}
+20 -1
View File
@@ -1,5 +1,5 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import { Contact, Message } from '../shared/types'
import { Contact, Message, MessagesAroundResult } from '../shared/types'
import {
GroupReportExportRequest,
GroupReportExportResult,
@@ -114,6 +114,12 @@ import type {
AiSearchPipelineResult,
AiSearchProgressEvent
} from '../shared/ai-search'
import type {
AskWechatConfig,
AskWechatQueryRequest,
AskWechatQueryResult,
QueryAgentProgressEvent
} from '../shared/query-agent'
import type {
KnowledgeRuntimeStatus,
KnowledgeSearchIpcRequest,
@@ -254,6 +260,12 @@ declare global {
endTime?: number,
options?: { limit?: number }
) => Promise<Message[]>
getMessagesAround: (
userMd5: string,
messageId: string,
anchorSeconds?: number,
radiusSeconds?: number
) => Promise<MessagesAroundResult>
getGroupSnapshot: (userMd5: string) => Promise<{
roomId: string
memberCount: number
@@ -284,8 +296,15 @@ declare global {
runAiSearch: (request: AiSearchPipelineRequest) => Promise<AiSearchPipelineResult>
cancelAiSearch: (requestId: string) => Promise<AiSearchCancelResult>
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>
startKnowledgeIndex: () => Promise<KnowledgeRuntimeStatus>
cancelKnowledgeIndex: () => Promise<{ cancellable: boolean; cancelled: boolean }>
onKnowledgeStatus: (callback: (status: KnowledgeRuntimeStatus) => void) => () => void
aiChat: (
messages: { role: string; content: string }[],
+45
View File
@@ -5,6 +5,7 @@ import type {
GroupReportRenderSnapshotExportRequest
} from '../shared/group-report'
import type { ReportTemplateOperationResult } from '../shared/report-template-package'
import type { MessagesAroundResult } from '../shared/types'
import type {
ReportTemplateCatalogInstallResult,
ReportTemplateCatalogResult
@@ -83,6 +84,12 @@ import type {
AiSearchPipelineResult,
AiSearchProgressEvent
} from '../shared/ai-search'
import type {
AskWechatConfig,
AskWechatQueryRequest,
AskWechatQueryResult,
QueryAgentProgressEvent
} from '../shared/query-agent'
import type {
KnowledgeRuntimeStatus,
KnowledgeSearchIpcRequest,
@@ -137,6 +144,17 @@ const api = {
endTime?: number,
options?: { limit?: number }
) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options),
/**
* 跳转到证据的锚点读取:按稳定消息 id 在有界时间窗口内精确定位,
* 返回 `found` 让 UI 能诚实降级(而不是假装跳成功)。
*/
getMessagesAround: (
userMd5: string,
messageId: string,
anchorSeconds?: number,
radiusSeconds?: number
): Promise<MessagesAroundResult> =>
ipcRenderer.invoke('db:getMessagesAround', userMd5, messageId, anchorSeconds, radiusSeconds),
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
getGroupExitMonitorState: (): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:getState'),
@@ -180,10 +198,37 @@ const api = {
ipcRenderer.on('ai-search:progress', listener)
return () => ipcRenderer.removeListener('ai-search:progress', listener)
},
getAskWechatConfig: (): Promise<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> =>
ipcRenderer.invoke('knowledge:getStatus'),
startKnowledgeIndex: (): Promise<KnowledgeRuntimeStatus> =>
ipcRenderer.invoke('knowledge:startIndex'),
/**
* 取消正在跑的索引 pass。返回 `cancelled: false` 表示请求时已经没有可取消的任务
* (例如刚好自己跑完了)——UI 必须如实反映,而不是无条件显示"已取消"。
*/
cancelKnowledgeIndex: (): Promise<{ cancellable: boolean; cancelled: boolean }> =>
ipcRenderer.invoke('knowledge:cancelIndex'),
onKnowledgeStatus: (callback: (status: KnowledgeRuntimeStatus) => void) => {
const listener = (_event: Electron.IpcRendererEvent, status: KnowledgeRuntimeStatus): void =>
callback(status)
+46 -11
View File
@@ -28,6 +28,8 @@ import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/Dat
import { FirstUseWelcome } from './components/FirstUseWelcome'
import { ExportWorkspace } from './components/export/ExportWorkspace'
import { AISearchWorkspace } from './components/search/AISearchWorkspace'
import type { EvidenceItem } from './components/search/searchTypes'
import { decodeMessageRef } from '../../shared/local-query-api'
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../shared/database-key'
import {
@@ -249,6 +251,13 @@ function App(): React.ReactElement {
const connectionOperationRef = React.useRef(0)
const [activePage, setActivePage] = useState<AppPage>('archive')
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 [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
@@ -1191,6 +1200,7 @@ function App(): React.ReactElement {
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
setArchiveJumpTime(null)
setArchiveJumpMessageId(null)
setSelectedContact(contact)
selectedContactMd5Ref.current = contact.md5
currentGroupSnapshotRef.current = null
@@ -1265,23 +1275,49 @@ function App(): React.ReactElement {
}
}
const handleOpenSearchEvidence = async (contact: Contact, createTime?: number): Promise<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')
await handleSelectContact(contact)
if (!createTime || selectedContactMd5Ref.current !== contact.md5) return
if (selectedContactMd5Ref.current !== contact.md5) return
const anchorSeconds = evidence.message.createTime || undefined
try {
const windowStart = Math.max(0, createTime - 12 * 3600)
const windowEnd = createTime + 12 * 3600
const nearbyMessages = await window.api.getMessages(contact.md5, windowStart, windowEnd)
const around = await window.api.getMessagesAround(
contact.md5,
anchor.messageId,
anchorSeconds
)
if (selectedContactMd5Ref.current !== contact.md5) return
const focusedMessages = sortMessagesChronologically(nearbyMessages)
const focusedMessages = sortMessagesChronologically(around.messages)
messageHistoryRef.current = focusedMessages
setMessages(applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, focusedMessages)))
setArchiveJumpTime(createTime)
if (around.found) {
setArchiveJumpMessageId(anchor.messageId)
setArchiveJumpTime(anchorSeconds ?? null)
} else {
setReportNotice('已打开对应会话,但暂时无法定位原消息。')
}
} catch (error) {
console.warn('[Search] evidence context load failed:', error)
setReportNotice('证据所在时间段加载失败,请在档案中手动查看')
setReportNotice('已打开对应会话,但暂时无法定位原消息。')
}
}
@@ -1773,6 +1809,7 @@ function App(): React.ReactElement {
onOpenPersonalWechatSettings={openWechatSendSettings}
isAiLoading={reportGeneration.isGenerating}
jumpToTime={archiveJumpTime}
jumpToMessageId={archiveJumpMessageId}
/>
</div>
)
@@ -2007,9 +2044,7 @@ function App(): React.ReactElement {
dbReady={isDatabaseConnected}
aiModelConfig={aiModelConfig}
onSelectContact={(contact) => void handleSelectContact(contact)}
onOpenEvidence={(contact, createTime) =>
void handleOpenSearchEvidence(contact, createTime)
}
onOpenEvidence={(evidence) => void handleOpenSearchEvidence(evidence)}
onOpenAISettings={openModelSettings}
onNotice={setReportNotice}
/>
+18 -8
View File
@@ -24,6 +24,8 @@ interface ChatWindowProps {
onOpenPersonalWechatSettings?: () => void
isAiLoading?: boolean
jumpToTime?: number | null
/** 精确跳转目标(消息 id)。与 jumpToTime 取或:任一存在就说明"这是一次跳转"。 */
jumpToMessageId?: string | null
}
const ChatWindow: React.FC<ChatWindowProps> = ({
@@ -41,7 +43,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onOpenTextToSpeechSettings,
onOpenPersonalWechatSettings,
isAiLoading = false,
jumpToTime
jumpToTime,
jumpToMessageId
}) => {
const isGroupChat = Boolean(
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
@@ -76,24 +79,30 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
setIsAtLatest(true)
}, [contact?.md5])
useEffect(() => {
if (jumpToTime !== undefined && jumpToTime !== null) setIsAtLatest(false)
}, [jumpToTime])
// 一次跳转 = 有精确目标或有时间目标。用统一判据,避免"只带了 messageId 但没带时间"
// 时自动滚到底把跳转结果顶掉(那会让用户看到"跳过去了但又被弹回最新")。
const hasJumpTarget =
(jumpToTime !== undefined && jumpToTime !== null) ||
(jumpToMessageId !== undefined && jumpToMessageId !== null)
useEffect(() => {
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return
if (hasJumpTarget) setIsAtLatest(false)
}, [hasJumpTarget])
useEffect(() => {
if (!isAtLatest || hasJumpTarget) return
const frame = window.requestAnimationFrame(() => scrollToBottom())
return () => window.cancelAnimationFrame(frame)
}, [isAtLatest, jumpToTime, messages, scrollToBottom])
}, [isAtLatest, hasJumpTarget, messages, scrollToBottom])
useEffect(() => {
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return
if (!isAtLatest || hasJumpTarget) return
const content = messageListRef.current?.querySelector('.virtual-message-list')
if (!content) return
const observer = new ResizeObserver(() => scrollToBottom())
observer.observe(content)
return () => observer.disconnect()
}, [contact?.md5, isAtLatest, jumpToTime, scrollToBottom])
}, [contact?.md5, isAtLatest, hasJumpTarget, scrollToBottom])
const openImagePreview = (imageUrl: string): void => {
setPreviewImage(imageUrl)
@@ -175,6 +184,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onReachTop={onLoadOlderMessages}
onImageClick={openImagePreview}
jumpToTime={jumpToTime}
jumpToMessageId={jumpToMessageId}
/>
<ChatStatusBar
count={filteredMessages.length}
@@ -3,6 +3,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import { Contact, Message } from '../../../../shared/types'
import { MessageGroup } from './MessageGroup'
import { buildMessageGroups } from './messageGrouping'
import { resolveMessageJumpTarget } from './messageJump'
interface MessageListProps {
contact: Contact
@@ -18,6 +19,13 @@ interface MessageListProps {
onReachTop?: () => Promise<void>
onImageClick: (imageUrl: string) => void
jumpToTime?: number | null
/**
* 精确跳转目标(规范化消息 id,即去掉 `local:` 前缀后的 WCDB 本地 id)。
*
* 优先于 `jumpToTime`:时间只能找到"附近的第一条",秒级时间戳在群聊里经常
* 对应多条消息,于是会定位并高亮错一条。有 id 时必须按 id 找。
*/
jumpToMessageId?: string | null
}
export function MessageList({
@@ -33,7 +41,8 @@ export function MessageList({
onScroll,
onReachTop,
onImageClick,
jumpToTime
jumpToTime,
jumpToMessageId
}: MessageListProps): React.ReactElement {
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
const groupsRef = React.useRef(groups)
@@ -47,15 +56,10 @@ export function MessageList({
overscan: 8
})
const virtualItems = virtualizer.getVirtualItems()
const jumpTarget = React.useMemo(() => {
if (jumpToTime === undefined || jumpToTime === null) return null
const groupIndex = groups.findIndex((group) =>
group.messages.some((message) => (message.createTime || 0) >= jumpToTime)
)
if (groupIndex < 0) return null
const message = groups[groupIndex].messages.find((item) => (item.createTime || 0) >= jumpToTime)
return { groupIndex, messageId: message?.id }
}, [groups, jumpToTime])
const jumpTarget = React.useMemo(
() => resolveMessageJumpTarget(groups, jumpToTime, jumpToMessageId),
[groups, jumpToTime, jumpToMessageId]
)
React.useEffect(() => {
if (!jumpTarget) return
@@ -70,6 +74,7 @@ export function MessageList({
const scrollElement = event.currentTarget
if (
(jumpToTime !== undefined && jumpToTime !== null) ||
(jumpToMessageId !== undefined && jumpToMessageId !== null) ||
scrollElement.scrollTop >= 48 ||
loadingOlderRef.current ||
isLoadingMessages ||
@@ -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>
</Button>
) : (
<Button
type="submit"
className="px-3 text-[11px]"
disabled={knowledgeSyncing}
title={knowledgeSyncing ? '知识库同步完成后才能开始分析' : undefined}
>
{knowledgeSyncing ? '同步中,暂不可分析' : '开始分析'}
// 同步中**不允许**禁用提问。后台同步是可取消 / 可断点续传的;索引没追平时按
// partial + freshness warning 如实作答(覆盖范围由主进程的 coverage/freshness 契约给出)。
<Button type="submit" className="px-3 text-[11px]">
开始分析
<span aria-hidden className="text-base leading-3">
→
</span>
@@ -153,7 +150,11 @@ export function AISearchComposer({
)}
</div>
<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>
</div>
</form>
@@ -1,6 +1,10 @@
import React, { useMemo, useRef, useState } from 'react'
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 {
AISearchWorkspaceProps,
@@ -15,14 +19,23 @@ import {
contactLabel,
formatBytes,
formatDuration,
formatIndexDate,
formatKnowledgeProcessed,
formatMeasuredDuration,
formatSearchTraceOverview,
knowledgeIsStale,
knowledgeStateLabel
} from './searchFormatters'
import { mapPipelineResultToRendererResult } from './searchMappers'
import { createSearchResultResetState, resolveSearchResultViewTransition } from './searchState'
import { useSearchHistory } from './hooks/useSearchHistory'
import { useKnowledgeStatus } from './hooks/useKnowledgeStatus'
import {
QUERY_AGENT_PROGRESS_STEPS,
queryAgentProgressLabel,
queryAgentProgressStepIndex,
useQueryAgentProgress
} from './hooks/useQueryAgentProgress'
import { useExternalProviderConsent } from './hooks/useExternalProviderConsent'
import { EVIDENCE_PAGE_SIZE, useEvidenceCollection } from './hooks/useEvidenceCollection'
import { useAiSearchRun } from './hooks/useAiSearchRun'
@@ -30,7 +43,18 @@ import { ensureAiSearchDataConsent } from './services/aiSearchProviderConsent'
import { ExternalProviderConsentDialog } from './ExternalProviderConsentDialog'
import { AISearchComposer } from './AISearchComposer'
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({
contacts,
@@ -63,6 +87,12 @@ export function AISearchWorkspace({
const [debugEntries, setDebugEntries] = useState<string[]>([])
const [appLogPath, setAppLogPath] = useState('')
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 {
evidence,
setEvidence,
@@ -128,8 +158,15 @@ export function AISearchWorkspace({
syncStarting,
knowledgeSyncing,
knowledgeSyncingRef,
startKnowledgeSync
cancelRequested,
startKnowledgeSync,
cancelKnowledgeSync
} = useKnowledgeStatus({ dbReady, onNotice })
const {
progress: qaProgress,
begin: beginQueryAgentProgress,
end: endQueryAgentProgress
} = useQueryAgentProgress()
const {
externalProviderConsent,
requestExternalProviderConsent,
@@ -155,6 +192,7 @@ export function AISearchWorkspace({
clearEvidenceCollection()
setCachedAt(reset.cachedAt)
setSearchTrace(reset.searchTrace)
setAskStats(null)
resetSearchRun()
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 entry = `${new Date().toLocaleTimeString('zh-CN')} ${message} ${JSON.stringify(details)}`
setDebugEntries((current) => [entry, ...current].slice(0, 80))
@@ -184,21 +233,58 @@ export function AISearchWorkspace({
const sourceLabel = {
global: '所有聊天记录',
groups: '群聊专属',
contacts: '联系人专属',
contacts: '单聊专属',
conversation: contactLabel(activeContact)
}[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
? contactLabel(
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
? `${aiModelConfig.providerName} · ${aiModelConfig.modelName}`
: '尚未配置 AI 模型'
const cancelAnalysis = async (): Promise<void> => {
clearExternalProviderConsent()
const requestId = searchRunRequestId
if (!requestId) return
const askRequestId = askRequestRef.current
if (!requestId && !askRequestId) return
askRequestRef.current = ''
setStage('idle')
setAnalysisError('')
setSearchDetailsOpen(false)
@@ -220,10 +306,9 @@ export function AISearchWorkspace({
): Promise<void> => {
event?.preventDefault()
if (stage === 'loading') return
if (knowledgeSyncingRef.current) {
onNotice('知识库正在同步,请等待同步完成后再开始分析')
return
}
// 索引同步中**不允许**禁止查询:同步是后台的、可取消的、可断点续传的;
// 索引没追平时按 partial + freshness warning 如实作答(覆盖范围由主进程的
// coverage/freshness 契约给出),绝不把用户挡在门外。
const {
normalizedQuery,
effectiveRange,
@@ -251,6 +336,43 @@ export function AISearchWorkspace({
setStage('insufficient')
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 {
const cached = consumeCacheBypass() ? null : readCachedResult(cacheKey)
if (cached) {
@@ -280,12 +402,77 @@ export function AISearchWorkspace({
onNotice('无法确认 AI 服务的数据发送授权,本次检索未执行')
return
}
// 索引正在追新:降级成提示而不是阻断。用户问的是"最近谁聊过 X",
// 拿一份明确标注覆盖范围的 partial 结果,永远好过一句"请等同步完成"。
if (knowledgeSyncingRef.current) {
onNotice('知识库正在同步,请等待同步完成后再开始分析')
return
onNotice('知识库正在后台同步,本次结果可能未覆盖最新消息')
}
setStage('loading')
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({
requestId,
text: normalizedQuery,
@@ -306,38 +493,7 @@ export function AISearchWorkspace({
setStage('insufficient')
return
}
const searchResult = outcome.result
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')
applyPipelineResult(outcome.result, effectiveRange)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '读取聊天记录失败'
addDebugEntry('检索失败', { error: errorMessage })
@@ -354,6 +510,9 @@ export function AISearchWorkspace({
const startNewQuestion = (): void => {
clearCacheBypass()
askRequestRef.current = ''
// 新问题 = 新的对话上下文:清掉 Query Agent 的澄清记忆,避免和上一次追问串味。
forgetAskWechatConversation()
setQuery('')
setResultQuery('')
setStage('idle')
@@ -413,6 +572,45 @@ export function AISearchWorkspace({
: 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 (
<div className="ai-search-loading">
<span className="ai-search-kicker">本地检索进行中</span>
@@ -674,27 +872,69 @@ export function AISearchWorkspace({
<div>
<span className="ai-search-kicker">✓ 已完成</span>
<h2>{resultQuery || query}</h2>
<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>
{askStats ? (
<>
<p>已生成回答</p>
<div className="ai-search-trace" aria-label="本次查询真实统计">
{formatAskWechatStats(askStats).map((chip) => (
<span key={chip}>{chip}</span>
))}
{askWechatToolLabels(askStats.tools).map((label) => (
<span key={label}>能力:{label}</span>
))}
</div>
{/* 耗时拆解:把总耗时还原成"AI 花了多少 / 本地查询花了多少"。
普通 UI 只出现这三个用户能理解的名字,不出现 firstModelMs / toolTotalMs
这类工程字段;逐次调用的细节只在 dev 模式展开,供排障用。 */}
{askStats.timings && (
<div className="ai-search-trace" aria-label="本次查询耗时拆解">
<span data-testid="query-timing-total">
总耗时 {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>
)
})()}
)}
</>
) : (
<>
<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()}
</div>
<div className="ai-search-result-actions">
@@ -902,89 +1142,169 @@ export function AISearchWorkspace({
当前会话{activeContact ? ` · ${contactLabel(activeContact)}` : ''}
</button>
</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 className="ai-search-filter-section ai-search-time-section">
<span className="ai-search-field-label">时间范围</span>
<div className="ai-search-time-menu">
{(Object.keys(RANGE_LABELS) as SearchRange[]).map((item) => (
<button
key={item}
type="button"
className={range === item ? 'active' : ''}
aria-pressed={range === item}
onClick={() => {
setRange(item)
setTimeRangeOverride({
startTime: aiSearchRangeStart(item),
endTime: undefined,
label: RANGE_LABELS[item],
reason: '用户在界面选择的时间范围',
source: 'user_selected'
})
}}
>
<span aria-hidden>{item === 'all' ? '▣' : item === 'today' ? '▤' : '◷'}</span>
{item === 'all' ? '不限时间' : RANGE_LABELS[item]}
</button>
))}
</div>
</section>
{queryAgentEnabled ? (
<section className="ai-search-filter-section">
<span className="ai-search-field-label">时间</span>
<p className="mt-1 text-[11px] leading-[17px] text-muted-foreground">
时间直接写在问题里,例如「上个月 BOBO 发过什么文件?」「最近 7 天群里聊了什么?」
</p>
</section>
) : (
<section className="ai-search-filter-section ai-search-time-section">
<span className="ai-search-field-label">时间范围</span>
<div className="ai-search-time-menu">
{(Object.keys(RANGE_LABELS) as SearchRange[]).map((item) => (
<button
key={item}
type="button"
className={range === item ? 'active' : ''}
aria-pressed={range === item}
onClick={() => {
setRange(item)
setTimeRangeOverride({
startTime: aiSearchRangeStart(item),
endTime: undefined,
label: RANGE_LABELS[item],
reason: '用户在界面选择的时间范围',
source: 'user_selected'
})
}}
>
<span aria-hidden>{item === 'all' ? '▣' : item === 'today' ? '▤' : '◷'}</span>
{item === 'all' ? '不限时间' : RANGE_LABELS[item]}
</button>
))}
</div>
</section>
)}
<section
className={`ai-search-knowledge-card ${knowledgeStatus?.state || 'unavailable'}`}
aria-label="知识库同步状态"
>
<div className="ai-search-knowledge-card-heading">
<div>
<span>KNOWLEDGE BASE</span>
<strong>{knowledgeStateLabel(knowledgeStatus)}</strong>
{/* 卡片自上而下固定五段:HEADER → CURRENT PASS → DATABASE STATUS → CURRENT → ACTION。
每段的「标签 / 数值」行都用同一套栅格(label 可收缩、value 取自然宽且不折断),
侧栏只有 ~145px 可用宽度,靠栅格而不是靠缩字号来避免挤成一团。 */}
<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>
<span className="ai-search-knowledge-dot" aria-hidden />
</div>
<p className="ai-search-knowledge-description">
{knowledgeStatus?.state === 'unavailable'
? '知识库不会自动建立,只有点击下方按钮后才会在后台同步。'
: '后台增量同步不会影响原始微信聊天记录。'}
: knowledgeStatus?.state === 'cancelled'
? '同步已取消。已经建立的索引仍然可用,下次同步会从中断处继续,不会从头重扫。'
: knowledgeIsStale(knowledgeStatus) && knowledgeStatus?.indexLatestAt
? `索引还没追上最新聊天:跨会话搜索目前只覆盖到 ${formatIndexDate(knowledgeStatus.indexLatestAt)},之后的记录需要同步后才可检索。不影响你现在提问,但答案会标注覆盖范围。`
: '后台增量同步不会影响原始微信聊天记录,也不会阻塞提问。'}
</p>
{(knowledgeStatus?.state === 'building' || knowledgeStatus?.state === 'syncing') && (
<div className="ai-search-sync-progress">
<div className="ai-search-sync-progress-top">
<span>
已处理 {knowledgeStatus.processedMessages.toLocaleString()} 条
{knowledgeStatus.totalMessages
? ` / ${knowledgeStatus.totalMessages.toLocaleString()}`
: ''}
</span>
<span>
{knowledgeStatus.totalMessages
? `${Math.min(100, Math.round((knowledgeStatus.processedMessages / knowledgeStatus.totalMessages) * 100))}%`
: '统计中'}
</span>
{/* CURRENT PASS:只有这一遍真的在跑时才出现。 */}
{knowledgeIsRunning && (
<div className="ai-search-knowledge-pass">
<div className="ai-search-knowledge-rows">
<div className="ai-search-knowledge-row">
<span className="ai-search-knowledge-label">会话进度</span>
<strong className="ai-search-knowledge-value">
{knowledgeStatus.pass
? `${knowledgeStatus.pass.processedConversations.toLocaleString()} / ${knowledgeStatus.pass.totalConversations.toLocaleString()}`
: '准备中'}
</strong>
</div>
</div>
<div className="ai-search-sync-progress-track">
<span
style={{
width: knowledgeStatus.totalMessages
? `${Math.min(100, (knowledgeStatus.processedMessages / knowledgeStatus.totalMessages) * 100)}%`
: '35%'
// 有真实分母时用真实比例;没有分母时不再假装 35% 的"假进度条",
// 改成一条不确定态(UI 上用动画表示"在跑")。
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>
{/* 「已处理 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 className="ai-search-knowledge-details">
<div>
<span>已索引消息</span>
<strong>{(knowledgeStatus?.indexedMessageCount || 0).toLocaleString()}</strong>
<div className="ai-search-knowledge-rows">
<div className="ai-search-knowledge-row">
<span className="ai-search-knowledge-label">已索引消息</span>
<strong className="ai-search-knowledge-value">
{(knowledgeStatus?.indexedMessageCount || 0).toLocaleString()}
</strong>
</div>
<div>
<span>知识片段</span>
<strong>{(knowledgeStatus?.indexedChunkCount || 0).toLocaleString()}</strong>
<div className="ai-search-knowledge-row">
<span className="ai-search-knowledge-label">知识片段</span>
<strong className="ai-search-knowledge-value">
{(knowledgeStatus?.indexedChunkCount || 0).toLocaleString()}
</strong>
</div>
<div>
<span>磁盘占用</span>
<strong>
{knowledgeStatus?.indexLatestAt ? (
<div className="ai-search-knowledge-row">
<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(
(knowledgeStatus?.databaseBytes || 0) +
(knowledgeStatus?.walBytes || 0) +
@@ -992,41 +1312,70 @@ export function AISearchWorkspace({
)}
</strong>
</div>
{knowledgeStatus?.currentConversationId &&
(knowledgeStatus.state === 'building' || knowledgeStatus.state === 'syncing') && (
<div>
<span>当前会话</span>
<strong>
{currentSyncConversation === '未选择会话'
? '正在切换会话'
: currentSyncConversation}
</div>
{(knowledgeStatus?.currentConversationId && knowledgeIsRunning) ||
(knowledgeStatus?.pass && knowledgeStatus.pass.mainLoopLagMs > 0) ? (
<div className="ai-search-knowledge-rows">
{knowledgeStatus?.currentConversationId && knowledgeIsRunning && (
<div className="ai-search-knowledge-row">
<span className="ai-search-knowledge-label">当前会话</span>
{/* 会话名可以很长(群名 / 备注),这里必须省略而不是撑破侧栏。 */}
<strong
className="ai-search-knowledge-value ai-search-knowledge-value--truncate"
title={knowledgeCurrentConversationName}
>
{knowledgeCurrentConversationName}
</strong>
</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' && (
<p className="ai-search-knowledge-error">
{knowledgeStatus.lastError || '同步异常,旧搜索仍可使用。'}
{knowledgeStatus.lastError || '同步异常,已建立的索引仍可使用。'}
</p>
)}
<Button
size="sm"
className="w-full"
disabled={
syncStarting ||
knowledgeStatus?.state === 'building' ||
knowledgeStatus?.state === 'syncing'
}
onClick={() => void startKnowledgeSync()}
>
{syncStarting ||
knowledgeStatus?.state === 'building' ||
knowledgeStatus?.state === 'syncing'
? '同步中…'
: knowledgeStatus?.state === 'ready'
? '同步最新记录'
: '建立本地知识库'}
</Button>
{knowledgeStatus?.state === 'cancelled' && (
<p className="ai-search-knowledge-error">
上一遍同步被取消,已建立的索引仍然可用;下次同步会从断点继续。
</p>
)}
<div className="ai-search-knowledge-actions">
<Button
size="sm"
className="ai-search-knowledge-primary"
disabled={syncStarting || cancelRequested || knowledgeIsRunning}
onClick={() => void startKnowledgeSync()}
>
{syncStarting
? '启动中…'
: knowledgeIsRunning
? '同步中…'
: knowledgeStatus?.indexedMessageCount
? '同步最新记录'
: '建立本地知识库'}
</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">
<summary>同步详情</summary>
<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 type { Contact } from '../../../../../shared/types'
import type { EvidenceItem } from '../searchTypes'
export const EVIDENCE_PAGE_SIZE = 8
type UseEvidenceCollectionOptions = {
onOpenEvidence: (contact: Contact, createTime?: number) => void
/** 传整条证据:跳转需要它的稳定引用(messageRef),不只是会话与时间。 */
onOpenEvidence: (evidence: EvidenceItem) => void
}
export function useEvidenceCollection({ onOpenEvidence }: UseEvidenceCollectionOptions): {
@@ -70,7 +70,7 @@ export function useEvidenceCollection({ onOpenEvidence }: UseEvidenceCollectionO
const jumpToEvidence = (index: number): void => {
const item = evidenceCollection[index]
if (!item) return
onOpenEvidence(item.contact, item.message.createTime)
onOpenEvidence(item)
}
const setEvidenceCardRef = (index: number, node: HTMLElement | null): void => {
@@ -11,10 +11,20 @@ export function useKnowledgeStatus({ dbReady, onNotice }: UseKnowledgeStatusOpti
syncStarting: boolean
knowledgeSyncing: boolean
knowledgeSyncingRef: React.MutableRefObject<boolean>
cancelRequested: boolean
startKnowledgeSync: () => Promise<void>
cancelKnowledgeSync: () => Promise<void>
} {
const [knowledgeStatus, setKnowledgeStatus] = useState<KnowledgeRuntimeStatus | null>(null)
const [syncStarting, setSyncStarting] = useState(false)
/**
* 本地「取消已发出、但还没落地」的状态。
*
* Worker 侧 abort 之后,当前会话还要安全收尾(事务提交 / 不残留 indexing),
* 主进程的 `pass.cancellable=false` 会先到,`phase='cancelled'` 后到。
* 这中间的窗口如果只靠 status 渲染,按钮会闪回"取消同步"。
*/
const [cancelRequested, setCancelRequested] = useState(false)
const knowledgeSyncingRef = useRef(false)
const knowledgeSyncing =
syncStarting || knowledgeStatus?.state === 'building' || knowledgeStatus?.state === 'syncing'
@@ -29,7 +39,11 @@ export function useKnowledgeStatus({ dbReady, onNotice }: UseKnowledgeStatusOpti
})
.catch(() => undefined)
const unsubscribe = window.api.onKnowledgeStatus((status) => {
if (active) setKnowledgeStatus(status)
if (!active) return
setKnowledgeStatus(status)
// 一遍 pass 真的结束了(不再可取消 / 已经有终态)→ 清掉本地的"正在取消"。
const phase = status.pass?.phase
if (phase === 'idle' || phase === 'cancelled' || phase === 'error') setCancelRequested(false)
})
return () => {
active = false
@@ -43,6 +57,7 @@ export function useKnowledgeStatus({ dbReady, onNotice }: UseKnowledgeStatusOpti
return
}
setSyncStarting(true)
setCancelRequested(false)
try {
const status = await window.api.startKnowledgeIndex()
setKnowledgeStatus(status)
@@ -58,11 +73,44 @@ export function useKnowledgeStatus({ dbReady, onNotice }: UseKnowledgeStatusOpti
}
}
/**
* 取消同步。
*
* 关键语义(不能简化成"点一下就当取消成功"):
* - 只有真的中止到了任务,才提示"已取消";
* - `cancelled: false` 表示请求时已经没有可取消的任务(例如刚好自己跑完了),
* 这时候说"已取消"是假话;
* - 已索引数据不会被清空,下次同步会从断点继续。
*/
const cancelKnowledgeSync = async (): Promise<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 {
knowledgeStatus,
syncStarting,
knowledgeSyncing,
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 { isKnowledgeFresh } from '../../../../shared/knowledge'
import type { Contact } from '../../../../shared/types'
import type { SearchTrace } from './searchTypes'
@@ -18,15 +19,67 @@ export const formatMeasuredDuration = (milliseconds: number | undefined): string
export const formatEvidenceTimestamp = (timestamp: number): string =>
new Date(timestamp).toLocaleString('zh-CN', { hour12: false })
/** 只显示到日期,用于「索引更新至 8/26」这类如实口径。 */
export const formatIndexDate = (timestamp: number): string =>
new Date(timestamp).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
/**
* 知识库状态文案。
*
* 两个**互相独立**的维度:
* 1. 新鲜度:索引覆盖到源数据的哪个时刻 → `indexLatestAt` / `sourceLatestAt`
* 2. 这一遍 pass 的进度 → `pass.phase` / `pass.cancellable`
*
* 约束:
* - 只要索引还能查,文案必须是「可用 · …」;
* - **绝不**在没有 pending gap 之前说「已同步」;
* - 「正在追新」与「正在补齐历史」必须分开(前者只补新消息,后者在建库)。
*/
export const knowledgeStateLabel = (status: KnowledgeRuntimeStatus | null): string => {
if (!status) return '读取中'
return {
unavailable: '未建立',
building: '建立中',
syncing: '增量同步',
ready: '已同步',
error: '异常'
}[status.state]
const phase = status.pass?.phase
const usable = status.indexedMessageCount > 0 || status.indexedChunkCount > 0
if (status.state === 'error' || phase === 'error') return usable ? '可用 · 更新失败' : '更新失败'
if (status.state === 'cancelled' || phase === 'cancelled')
return usable ? '可用 · 同步已取消' : '同步已取消'
// 一个分片都没有:这不是"落后",是"还没建立",不该带"可用"前缀。
// `unavailable` 必须在这里被吃掉:派生库不可查询时,残留的历史计数不能让它
// 冒充「可用 · 已追至最新」(那是两句真话拼成的假话)。
if (status.state === 'unavailable' || !usable) {
return status.state === 'building' ? '正在建立' : '未建立'
}
if (status.state === 'building') return '可用 · 正在补齐历史'
if (status.state === 'syncing') {
// 已经有分片还在跑:`full`(首次建库)与 `backfill`(补历史缺口)都不算"追新",
// 只有 `catchup`(读 delta 追最新)才是用户最关心、也最快的那个阶段。
return phase === 'full' || phase === 'backfill' ? '可用 · 正在补齐历史' : '可用 · 正在追新'
}
if (isKnowledgeFresh(status) === true) return '可用 · 已追至最新'
// ready 但落后、且当前没有 pass 在跑 = 有内容还没追到,但需要用户触发同步。
return '可用 · 待追新'
}
/** 索引落后于源数据时为 true,用于卡片描述与详情行。 */
export const knowledgeIsStale = (status: KnowledgeRuntimeStatus | null): boolean =>
Boolean(status) && status!.state === 'ready' && isKnowledgeFresh(status!) === false
/**
* 「已处理」这类计数必须带分母或者换成诚实语义:只有分母未知的绝对值会把"这一遍扫描"
* 说成"总量"。这里优先给分母;没有分母时退回**明确标注为单轮**的语义。
*/
export const formatKnowledgeProcessed = (status: KnowledgeRuntimeStatus): string => {
if (status.pass) {
const { indexedMessages, scannedMessages } = status.pass
return `本轮新增索引 ${indexedMessages.toLocaleString()} 条 · 本轮已扫描 ${scannedMessages.toLocaleString()} 条`
}
if (status.totalMessages) {
const percent = Math.min(
100,
Math.round((status.processedMessages / status.totalMessages) * 100)
)
return `${percent}%(${status.processedMessages.toLocaleString()} / ${status.totalMessages.toLocaleString()})`
}
return `本轮已处理 ${status.processedMessages.toLocaleString()} 条`
}
export const contactLabel = (contact: Contact | null | undefined): string =>
@@ -1,4 +1,5 @@
import type { AiSearchFinalEvidence, AiSearchPipelineResult } from '../../../../shared/ai-search'
import { encodeMessageRef } from '../../../../shared/local-query-api'
import type { Contact } from '../../../../shared/types'
import { compactCacheItem } from './searchUtils'
import type { AISearchCacheRecord, EvidenceItem, SearchTrace } from './searchTypes'
@@ -24,6 +25,9 @@ export const mapPipelineEvidenceItem = (
evidenceId: item.id,
sourceKind: item.sourceKind,
contact,
// 这条路径本来就同时知道真实会话 id 与消息 id,顺手补上稳定引用,
// 让 Legacy / ai-search 证据也能被精确定位(而不是只有 Query Agent 路径能跳准)。
...(safeRef(item.conversationId, item.messageId) || {}),
message: {
id: item.messageId,
from: item.senderId || 'user',
@@ -38,6 +42,15 @@ export const mapPipelineEvidenceItem = (
}
}
/** 引用构造失败(缺 id)时返回 null,调用方退化到按时间定位 —— 不允许抛异常打断结果渲染。 */
function safeRef(conversationId: string, messageId: string): { messageRef: string } | null {
try {
return { messageRef: encodeMessageRef(conversationId, messageId) }
} catch {
return null
}
}
export const mapPipelineEvidence = (
items: AiSearchFinalEvidence[],
contacts: Contact[]
@@ -36,6 +36,14 @@ export interface EvidenceItem {
sourceKind?: KnowledgeMessageKind
contact: Contact
message: Message
/**
* 稳定消息引用(opaque,可还原成 `{conversationId, messageId}`)。
*
* 只靠「会话 + 秒级时间戳」无法定位到**这一条**消息 —— 同一秒可能有多条,
* 而且时间戳只能定位到"附近"。Archive 的跳转优先用它。
* 老缓存记录 / Legacy 路径可能没有它,所以必须是可选的。
*/
messageRef?: string
}
export interface AISearchCacheRecord {
@@ -82,7 +90,13 @@ export interface AISearchWorkspaceProps {
dbReady: boolean
aiModelConfig: AIRuntimeModelConfig
onSelectContact: (contact: Contact) => void
onOpenEvidence: (contact: Contact, createTime?: number) => void
/**
* 跳转到证据的原聊天。
*
* 传整条 EvidenceItem 而不是 `(contact, createTime)`:后者丢掉了稳定身份(messageRef),
* 跳转只能靠"会话 + 秒级时间戳"猜,而会话 id 若来自展示层合成的 key 则完全跳不过去。
*/
onOpenEvidence: (evidence: EvidenceItem) => void
onOpenAISettings: () => void
onNotice: (message: string) => void
}
@@ -287,8 +287,11 @@ export const senderName = (
return contact.type === 'user' ? contact.m_nsNickName || '联系人' : '群成员'
}
export const compactCacheItem = ({ evidenceId, contact, message }: EvidenceItem): EvidenceItem => ({
export const compactCacheItem = ({ evidenceId, contact, message, messageRef }: EvidenceItem): EvidenceItem => ({
evidenceId,
// 稳定引用必须一起进缓存:否则命中缓存后「跳转到原聊天」会退化成按时间戳猜
// (缓存写入是最容易漏掉新字段的地方,这里显式列出而不是展开对象)。
...(messageRef ? { messageRef } : {}),
contact: {
md5: contact.md5,
m_nsUsrName: contact.m_nsUsrName,
+95 -16
View File
@@ -211,6 +211,11 @@
border: 1px solid hsl(var(--tm-border-subtle));
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
/* 侧栏最窄时卡片正文只有 ~145px。这里保持默认的 word-break / overflow-wrap:
中文长句交给「行栅格 + 显式换行点」处理,不能在词中间乱断,也绝不允许撑破侧栏。 */
min-width: 0;
word-break: normal;
overflow-wrap: normal;
}
.ai-search-knowledge-card.building,
@@ -226,31 +231,42 @@
border-color: color-mix(in srgb, var(--wxex-warning) 70%, var(--wxex-border));
}
.ai-search-knowledge-card-heading,
.ai-search-sync-progress-top,
.ai-search-knowledge-details > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
/* HEADER:左列(品牌 + 状态)可收缩,右侧状态点取自然宽。 */
.ai-search-knowledge-heading {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
column-gap: 8px;
min-width: 0;
}
.ai-search-knowledge-card-heading > div {
.ai-search-knowledge-heading-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.ai-search-knowledge-card-heading span:first-child {
.ai-search-knowledge-kicker {
color: var(--wxex-text-muted);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
white-space: nowrap;
}
.ai-search-knowledge-card-heading strong {
/* 状态文案允许折成两行,但只断在「 · 」之后。
`keep-all` 禁止在中文的字与字之间断行,「 · 」两侧的空格仍然是断点,
于是窄侧栏下只会变成「可用 · 」/「待追新」,不会碎成「可用 · 正在」/「补齐历史」。
`break-word` 只作兜底:万一某一段本身超过可用宽度,允许溢出时在词内断开。 */
.ai-search-knowledge-state {
min-width: 0;
color: var(--wxex-text-primary);
font-size: 12px;
font-weight: 700;
line-height: 16px;
word-break: keep-all;
overflow-wrap: break-word;
}
.ai-search-knowledge-description,
@@ -266,14 +282,22 @@
color: var(--wxex-warning);
}
.ai-search-sync-progress {
/* CURRENT PASS */
.ai-search-knowledge-pass {
display: grid;
gap: 5px;
min-width: 0;
}
.ai-search-sync-progress-top {
.ai-search-knowledge-pass-line {
margin: 0;
min-width: 0;
color: var(--wxex-text-secondary);
font-size: 10px;
line-height: 15px;
/* 与状态标题同一条折行规则:只在「 · 」之后断句。 */
word-break: keep-all;
overflow-wrap: break-word;
}
.ai-search-sync-progress-track {
@@ -288,6 +312,7 @@
height: 100%;
border-radius: inherit;
background: var(--wxex-brand);
transform-origin: left center;
transition: width 250ms ease;
}
@@ -306,21 +331,75 @@
}
}
.ai-search-knowledge-details {
/* 没有真实分母时进度条改为不确定态(JSX 内联 style 挂着这个动画名)。
没有这段 keyframes 时那条 100% 宽的进度条是静止的,看起来像"已经跑完了"。 */
@keyframes ai-search-indeterminate {
0% {
transform: translateX(-100%) scaleX(0.35);
}
100% {
transform: translateX(285%) scaleX(0.35);
}
}
/* DATABASE STATUS / CURRENT:一套行栅格。
标签列 minmax(0, 1fr) 可收缩;数值列 minmax(0, auto) 取自然宽,
空间不足时收缩的是数值列(配合 --truncate 省略),数字永远不被折断。 */
.ai-search-knowledge-rows {
display: grid;
gap: 5px;
padding-top: 2px;
min-width: 0;
}
.ai-search-knowledge-details span {
.ai-search-knowledge-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, auto);
align-items: baseline;
column-gap: 8px;
min-width: 0;
}
.ai-search-knowledge-label {
min-width: 0;
overflow: hidden;
color: var(--wxex-text-muted);
font-size: 10px;
line-height: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-search-knowledge-details strong {
.ai-search-knowledge-value {
min-width: 0;
color: var(--wxex-text-secondary);
font-size: 10px;
font-variant-numeric: tabular-nums;
font-weight: 600;
line-height: 15px;
text-align: right;
white-space: nowrap;
}
.ai-search-knowledge-value--truncate {
overflow: hidden;
text-overflow: ellipsis;
}
/* ACTION:主按钮吃满剩余宽度,取消按钮取自然宽且永不换行。 */
.ai-search-knowledge-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
min-width: 0;
}
.ai-search-knowledge-primary {
min-width: 0;
}
.ai-search-knowledge-cancel {
min-width: 68px;
white-space: nowrap;
}
.ai-search-knowledge-more {
+119 -2
View File
@@ -100,6 +100,17 @@ export interface KnowledgeConversationInput {
/** true means this is a complete read-only snapshot of the conversation. */
completeSnapshot: boolean
messages: KnowledgeSourceMessage[]
/**
* 这个会话在**源侧**(WCDB Session.last_timestamp)已经覆盖到的最后活跃时间(epoch ms)。
*
* 与 `KnowledgeIndexRequest.sourceLatestAt` 的区别:后者是整遍 pass 级别的边界,这里是
* per-conversation checkpoint —— 增量 pass 用它判断「这个会话有没有新消息」,
* 从而跳过整个会话(不读 WCDB、不传 IPC、不写索引)。
*
* 记录**源侧**时间而不是「索引里最后一条可建模消息的时间」:否则最后一条恰好落在
* 图片/空正文上的会话会永远被判定为「有新消息」,增量永远跳不过它。
*/
sourceHighWaterTime?: number
}
export interface KnowledgeIndexRequest {
@@ -110,6 +121,13 @@ export interface KnowledgeIndexRequest {
fts: KnowledgeFtsConfig
/** Written only after a complete source pass; used for truthful coverage. */
sourceMessageCount?: number
/**
* 这一遍完整 pass 实际扫到的源数据最新消息时间(epoch ms)。
*
* 与 `sourceMessageCount` 一样,只在读完全部会话的那一次写入。它是 freshness 的权威口径:
* 拿它与当前源数据最新活跃时间比较,就能确定索引是否已经追上,而不必猜测派生索引的过滤落差。
*/
sourceLatestAt?: number
}
export interface KnowledgeIndexProgress {
@@ -236,6 +254,18 @@ export interface KnowledgeSearchTimings {
responseSerializeMs: number
/** FTS (or short-term database lookup) query time. */
ftsMs: number
/**
* `ftsMs` 中**短词回退路径**(<3 字的中文词,走 `LIKE` 而非 FTS `MATCH`)占用的时间。
*
* 跨会话 lexical probe 里最常见的 2 字中文词只能走这条路径,没有这个分解就无法区分
* 「FTS MATCH 慢」和「短词全表 LIKE 慢」。
*/
shortTermSearchMs?: number
/**
* `getSearchStatus()`(含统计快照判定)占用的时间。
* 用于验证「搜索热路径不再做全表聚合」这一性能约束没有被回退。
*/
statusMs?: number
/** Reading source message rows from matching chunks. */
messageLoadMs: number
/** Expanding chunk members, scoring terms and per-chunk de-duplication. */
@@ -285,6 +315,13 @@ export interface KnowledgeSearchResult {
evidence: KnowledgeEvidence[]
indexedMessageCount: number
indexedChunkCount: number
/**
* 派生索引里最新的消息时间(epoch ms);null 表示无法判定。
*
* `state: 'ready'` 只说明「这个派生库可以被查询」,**不等于**它已经追到源数据最新位置。
* 调用方必须把它与请求的时间范围比较,才能判断本次检索是否覆盖了用户问的时间。
*/
indexLatestAt: number | null
timings: KnowledgeSearchTimings
conversationRetrieval?: KnowledgeConversationRetrieval
voiceCoverage?: KnowledgeVoiceCoverage
@@ -308,9 +345,53 @@ export interface KnowledgeSearchIpcResult extends KnowledgeSearchResult {
source: 'knowledge' | 'fallback'
totalMessages: number
fallbackReason?: 'unavailable' | 'indexing' | 'error'
/**
* 源数据(WCDB Session)里最新的活跃时间(epoch ms);null 表示无法判定。
*
* 与 `indexLatestAt` 一起构成 freshness 判据:`sourceLatestAt > indexLatestAt`
* 说明源数据里已经有了索引还没覆盖的内容。
*/
sourceLatestAt: number | null
}
export type KnowledgeRuntimeState = 'unavailable' | 'building' | 'syncing' | 'ready' | 'error'
export type KnowledgeRuntimeState = 'unavailable' | 'building' | 'syncing' | 'ready' | 'error' | 'cancelled'
/**
* 一次后台索引 pass 的真实进度(ADDITIVE)。
*
* 与新鲜度(`indexLatestAt` / `sourceLatestAt`)回答的是不同问题:这里描述这一遍**在做什么、
* 扫了多少、真正写了多少**。
*/
export interface KnowledgePassProgress {
/**
* 这一遍在做什么:
* - `full` 首次全量建立(还没有任何可用分片)
* - `catchup` 追最新:已建立 checkpoint 的会话出现了新消息
* - `backfill` 补历史:从来没有 checkpoint 的会话(历史缺口)
* - `cancelled` / `error` / `idle`
*/
phase: 'idle' | 'full' | 'catchup' | 'backfill' | 'cancelled' | 'error'
/** 现在是否可以取消(真实在跑且未收到取消请求时才是 true)。 */
cancellable: boolean
/** 开始时间(epoch ms)。 */
startedAt: number
/** 这一遍从 WCDB **读取/扫描**的源消息条数(含被可索引性过滤掉的)。 */
scannedMessages: number
/** 这一遍真正**进入索引**的源消息条数。 */
indexedMessages: number
processedConversations: number
totalConversations: number
/** 因为「没有任何新消息」而整段跳过的会话数(增量 pass 的核心指标)。 */
skippedConversations: number
/** 需要「追最新」的会话数(已建立 checkpoint,且源侧有更新)。 */
catchupConversations: number
/** 需要「补历史」的会话数(从来没有 checkpoint,必须整段读)。 */
backfillConversations: number
/** 这一遍已经补齐的历史会话数。 */
backfillCompletedConversations: number
/** 主线程 event loop 在这一遍期间的最大滞后。用于验证重活没有压在 Main 上。 */
mainLoopLagMs: number
}
export interface KnowledgeRuntimeStatus {
accountId: string
@@ -328,6 +409,20 @@ export interface KnowledgeRuntimeStatus {
walBytes: number
shmBytes: number
lastError?: string
/**
* 派生索引里最新的消息时间(epoch ms);null 表示无法判定。
* `state: 'ready'`(READY)与「已追到源数据最新」(FRESH)是两个概念,它就是两者的判据之一。
*/
indexLatestAt: number | null
/** 源数据(WCDB Session)最新活跃时间(epoch ms);null 表示无法判定。 */
sourceLatestAt: number | null
/**
* 当前/最近一次后台索引 pass 的真实进度(ADDITIVE)。
*
* 与 `indexLatestAt` 是**两个不同维度**:前者回答"这一遍在补什么、进度多少",
* 后者回答"索引已经覆盖到源数据的哪个时刻"。UI 不能再用一个「已同步」把两者混为一谈。
*/
pass?: KnowledgePassProgress
}
export interface KnowledgeStatusRequest {
@@ -336,9 +431,31 @@ export interface KnowledgeStatusRequest {
fts: KnowledgeFtsConfig
}
/**
* 索引「已覆盖到的源数据时间」与源数据「当前最新活跃时间」之间允许的固定落差。
*
* 两侧都来自消息的 create_time(索引侧 = 完整 pass 扫到的最大 create_time,
* 源侧 = Session 行的 last_timestamp),用于吸收 Session 元数据晚于消息落库的漂移。
* main 与 renderer 共用这一份定义,避免 UI 与 Engine 的 freshness 口径漂移。
*/
export const KNOWLEDGE_FRESHNESS_TOLERANCE_MS = 60 * 1000
/**
* 索引是否已经追到源数据最新(FRESH)。
*
* 注意与 `state === 'ready'`(READY:这个派生库**可以被查询**)区分:
* READY 不代表 FRESH。任一侧口径缺失时返回 null 表示无法判定。
*/
export function isKnowledgeFresh(
status: Pick<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 {
version: 1
type: 'index' | 'preflight' | 'search' | 'status' | 'remove' | 'cancel' | 'close'
type: 'index' | 'preflight' | 'search' | 'status' | 'remove' | 'cancel' | 'close' | 'highWater'
requestId: string
/** Parent monotonic wall-clock used only for transport timing. */
sentAt?: number
+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[] = [
{ name: 'query_messages', description: '精确读取符合联系人、时间、方向、消息类型、顺序等结构条件的消息;适合具体事实和 earliest/latest 等时间边界查询,边界查询使用 order 与 limit。每次调用都必须声明 temporalBasis,说明这个时间范围来自用户的明确约束、模糊回忆线索,还是用户根本没给时间信息。', parameters: { type: 'object', required: ['target', 'timeRange', 'temporalBasis'], additionalProperties: false, properties: { target: targetSchema, timeRange: timeRangeSchema, temporalBasis: temporalBasisSchema, direction: { enum: ['any', 'from_target', 'to_target'] }, messageTypes: { type: 'array', items: { enum: ['text', 'image', 'voice', 'video', 'file', 'link', 'sticker', 'system', 'other'] } }, order: { enum: ['asc', 'desc'] }, limit: { type: 'integer', minimum: 1, maximum: 200 }, excludeSystem: { type: 'boolean' } } } },
{ name: 'search_messages', description: '在指定联系人和时间范围内做关键词检索并返回相关 Evidence。queries 的每一项都是一次独立的字面检索:一项只放一个简短关键词,不要把多个近义词或整句话放进同一项,也不要指望一项内部被拆词理解。首次最多 4 项;只有在本次检索完全没有 Evidence 时,才允许再检索一次,且每一项都必须与上一次实质不同。', parameters: { type: 'object', required: ['target', 'timeRange', 'queries'], additionalProperties: false, properties: { target: targetSchema, timeRange: timeRangeSchema, queries: { type: 'array', description: '独立检索项列表,每项一个简短关键词,最多 4 项;每一项单独检索,不会组合成一句话理解。', minItems: 1, maxItems: 4, items: { type: 'string', minLength: 1 } }, limit: { type: 'integer', minimum: 1, maximum: 200 } } } },
{ name: 'query_messages', description: '精确读取**单个**会话中符合联系人、时间、方向、消息类型、顺序等结构条件的消息;适合具体事实和 earliest/latest 等时间边界查询,边界查询使用 order 与 limit。省略 target 表示"当前搜索范围恰好只有一个会话"(例如单聊专属或当前会话)时直接查该会话;范围里有多个会话时必须显式指定 target,且 target 必须落在当前搜索范围内。每次调用都必须声明 temporalBasis,说明这个时间范围来自用户的明确约束、模糊回忆线索,还是用户根本没给时间信息。跨多个会话的"谁聊过某话题"请改用 search_messages。', parameters: { type: 'object', required: ['timeRange', 'temporalBasis'], additionalProperties: false, properties: { target: targetSchema, timeRange: timeRangeSchema, temporalBasis: temporalBasisSchema, direction: { enum: ['any', 'from_target', 'to_target'] }, messageTypes: { type: 'array', items: { enum: ['text', 'image', 'voice', 'video', 'file', 'link', 'sticker', 'system', 'other'] } }, order: { enum: ['asc', 'desc'] }, limit: { type: 'integer', minimum: 1, maximum: 200 }, excludeSystem: { type: 'boolean' } } } },
{ name: 'search_messages', description: '在应用当前的搜索范围内做关键词检索并返回相关 Evidence。queries 的每一项都是一次独立的字面检索:一项只放一个简短关键词,不要把多个近义词或整句话放进同一项,也不要指望一项内部被拆词理解。首次最多 4 项;只有在本次检索完全没有 Evidence 时,才允许再检索一次,且每一项都必须与上一次实质不同。省略 target 表示在整个当前搜索范围(可能是多个会话,例如所有群聊)内检索——问"最近谁聊过某个话题"这类跨会话问题时应当省略 target;只有当问题明确指向某一个会话时才传 target,且该 target 必须在当前搜索范围内。', parameters: { type: 'object', required: ['timeRange', 'queries'], additionalProperties: false, properties: { target: targetSchema, timeRange: timeRangeSchema, queries: { type: 'array', description: '独立检索项列表,每项一个简短关键词,最多 4 项;每一项单独检索,不会组合成一句话理解。', minItems: 1, maxItems: 4, items: { type: 'string', minLength: 1 } }, limit: { type: 'integer', minimum: 1, maximum: 200 } } } },
{ name: 'message_context', description: '补充已找到的单条有价值 Evidence 的前后消息;仅在该 Evidence 缺少语境、无法判断含义时使用,不是默认确认步骤。', parameters: { type: 'object', required: ['messageRef'], additionalProperties: false, properties: { messageRef: { type: 'string', minLength: 1 }, before: { type: 'integer', minimum: 0, maximum: 50 }, after: { type: 'integer', minimum: 0, maximum: 50 } } } },
{ name: 'conversation_overview', description: '提取指定联系人和时间范围的整体聊天覆盖样本;只用于 broad summary,不是语义搜索 fallback,也不能确定 earliest/latest 等精确时间边界。', parameters: { type: 'object', required: ['target', 'timeRange'], additionalProperties: false, properties: { target: targetSchema, timeRange: timeRangeSchema } } }
{ name: 'conversation_overview', description: '提取**单个**会话在一段时间内的整体聊天覆盖样本;只用于 broad summary,不是语义搜索 fallback,也不能确定 earliest/latest 等精确时间边界。省略 target 只在当前搜索范围恰好只有一个会话时成立(例如"当前会话");范围里有多个会话时必须显式指定 target,且目标必须落在该范围内。', parameters: { type: 'object', required: ['timeRange'], additionalProperties: false, properties: { target: targetSchema, timeRange: timeRangeSchema } } }
]
export type QueryTimeRange =
| { kind: 'all' | 'today' | 'yesterday' | 'this_week' | 'last_7_days' | 'this_month' | 'previous_month' | 'this_year' | 'previous_year' }
| { kind: 'absolute'; startTime?: number; endTime?: number }
export interface QueryTarget { query: string }
/**
* 语料边界(conversation scope)。
*
* 由**调用方(UI / Host)**决定,**不是** LLM 的输入 —— 它不进入 LLM-facing Tool schema,
* 也不与 temporalBasis(时间语义)混在一起:scope 是"去哪里搜",时间是"搜什么时候"。
*
* - `all`:所有可读会话(单聊 + 群聊,群聊成员的每条消息都是普通可搜索消息)
* - `groups`:只搜群聊语料(= 全部群会话及其成员消息),不是"群摘要 / metadata"
* - `contact`:只搜指定的一对一会话(单聊专属)
* - `current`:只搜当前打开的那个会话(可能是单聊或群)
*
* `conversationId` 使用应用内部会话身份(与 `Contact.md5` 一致)。
*/
export type QueryCorpusScope =
| { kind: 'all' }
| { kind: 'groups' }
| { kind: 'contact'; conversationId: string }
| { kind: 'current'; conversationId: string }
/** 解析后的语料边界(回显给调用方与模型,用于说明"这次只在哪个范围里查")。 */
export interface ResolvedCorpusScope {
kind: QueryCorpusScope['kind']
/** 实际参与检索的会话数量;`all` 为可读会话总数。 */
conversationCount: number
/** `contact` / `current` 解析出的会话展示名。 */
displayName?: string
conversationType?: 'user' | 'group'
}
/**
* Tool 返回的单条证据。
* 群消息必须能归属到**具体群 + 具体成员**,否则模型无法回答"谁聊过"。
*/
export interface QueryEvidenceItem
extends Pick<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 QueryMessagesRequest {
target: QueryTarget
/** 省略 = 使用当前搜索范围(仅当范围恰好只有一个会话时成立)。 */
target?: QueryTarget
timeRange: QueryTimeRange
direction?: QueryDirection
messageTypes?: QueryMessageType[]
order?: QueryOrder
limit?: number
excludeSystem?: boolean
/** 语料边界;省略 = 不限制(等价于 all)。目标必须落在该边界内,否则被 Host 拒绝。 */
scope?: QueryCorpusScope
}
export interface QueryMessage {
messageRef: string
@@ -99,6 +226,8 @@ export interface QueryMessagesResponse {
returnedCount?: number
messages?: QueryMessage[]
candidates?: Array<{ displayName: string; type: 'user' | 'group' }>
/** 本次实际使用的语料边界。 */
scope?: ResolvedCorpusScope
}
export interface SearchMessagesRequest {
target: QueryTarget
@@ -106,25 +235,104 @@ export interface SearchMessagesRequest {
query: string
variants?: string[]
limit?: number
/** 语料边界;省略 = 全部可读会话。省略 target 时用它作为跨会话检索范围。 */
scope?: QueryCorpusScope
}
export interface SearchMessagesResponse {
status: string
target?: { displayName: string; type: 'user' | 'group' }
resolvedTimeRange?: ResolvedTimeRange
/**
* 本次检索的覆盖度。
*
* `complete` 需要同时满足:派生索引可用 **且** 请求的时间范围被索引完整覆盖
* (索引已追到源数据最新,或 requested range 落在索引覆盖窗口内)。
* 只要源数据在索引之后还有内容,就必须是 `partial` —— 此时 0 条 Evidence
* **不能**被解释成「整个微信里没有」。
*/
coverage?: { state: 'complete' | 'partial' | 'unknown' }
probeCount?: number
evidenceCount?: number
evidence?: Array<Pick<KnowledgeEvidence, 'timestamp' | 'sender' | 'sourceKind' | 'text'> & { messageRef: string }>
evidence?: QueryEvidenceItem[]
candidates?: Array<{ displayName: string; type: 'user' | 'group' }>
scope?: ResolvedCorpusScope
/**
* 派生索引中最新一条消息的时间(epoch ms)。
* 索引是**异步派生**数据:它落后于 WCDB 时必须能被调用方看见,不能冒充"完整"。
*/
indexLatestAt?: number | null
/** 源数据(WCDB)里最新的活跃时间(epoch ms);与 indexLatestAt 比较即得 freshness。 */
sourceLatestAt?: number | null
/**
* 本次为追赶索引新鲜度做了什么(诊断字段,不参与答案语义)。
* - `none`:索引已覆盖请求范围,或没有可用的追赶通道
* - `reused`:已有索引任务在跑,直接复用,不阻塞本次查询
* - `skipped`:确实落后,但落后量不值得再跑一遍索引(低于门槛),按当前覆盖如实回答
* - `completed`:本次触发的追赶在等待预算内完成,并已用新索引重新检索
* - `pending`:本次触发了追赶但没在预算内完成;本次按当前覆盖如实回答,后台继续追
*/
freshness?: { catchUp: 'none' | 'reused' | 'skipped' | 'completed' | 'pending' }
/**
* 索引覆盖结论(模型可直接引用的一句话)。
*
* 只给结构化数字时模型会自己换算、甚至反过来宣称"覆盖完整"。这里由 Engine 直接给出
* **本地时间**与结论,模型只需引用,不需要自己判断,也不需要输出 epoch 数字。
*/
indexCoverage?: QueryIndexCoverage
/** 本次检索的真实耗时分解(ADDITIVE,用于诊断与 UI 展示;不进入模型上下文)。 */
timings?: QuerySearchTimings
}
export interface MessageContextRequest { messageRef: string; before?: number; after?: number }
export interface QueryIndexCoverage {
/** 索引是否已覆盖本次请求的时间范围。 */
covered: boolean
/** 索引覆盖到的时间(本地时间,`MM-DD HH:mm`)。 */
indexLatestAtLabel?: string
/** 源数据最新时间(本地时间,`MM-DD HH:mm`)。 */
sourceLatestAtLabel?: string
/** 可直接引用的结论句;`covered: false` 时明确说明这段时间暂时无法确认。 */
summary: string
}
/**
* `search_messages` 的真实耗时分解(ADDITIVE 诊断字段)。
*
* 让 Tool 阶段可以被拆成 freshness 等待 / scope 解析 / 每个 probe / 证据补全,
* 而不是一个不透明的总数。这些数字全部来自实际 `Date.now()` 测量,不做任何推测。
*
* **不进入模型上下文**:Host 在把 Tool Result 交给模型之前会剥离它。
*/
export interface QuerySearchTimings {
/** 本次 search_messages 端到端耗时(含 freshness 处理与重检索)。 */
totalMs: number
/** 语料范围解析(联系人列表读取 + scope 展开成会话集合)。 */
scopeMs: number
/** 索引新鲜度判定 + 必要时触发追赶并在预算内等待。 */
freshnessMs: number
/** 每个 probe 的检索耗时,顺序与请求中的 probe 一致(可能含重检索后的第二次)。 */
probeMs: number[]
/** 跨 probe 证据合并 / 去重 / 排序。 */
mergeMs: number
/** 证据展示信息补全(群名 / 成员昵称)所等待的时间,主要是 WCDB 读取。 */
enrichmentMs: number
/** 派生库内部的分项(worker 侧测量)。 */
knowledge?: {
shortTermSearchMs: number
ftsMs: number
messageLoadMs: number
statusMs: number
voiceCoverageMs: number
workerExecutionMs: number
}
}
export interface MessageContextRequest { messageRef: string; before?: number; after?: number; scope?: QueryCorpusScope }
export interface MessageContextResponse {
status: string
anchor?: QueryMessage
before?: QueryMessage[]
after?: QueryMessage[]
}
export interface ConversationOverviewRequest { target: QueryTarget; timeRange: QueryTimeRange }
export interface ConversationOverviewRequest { target: QueryTarget; timeRange: QueryTimeRange; scope?: QueryCorpusScope }
export interface ConversationOverviewResponse {
status: string
target?: { displayName: string; type: 'user' | 'group' }
@@ -135,8 +343,14 @@ export interface ConversationOverviewResponse {
sourceCoverage?: { state: 'complete' | 'partial' | 'unknown'; sourceMessageCount: number }
selection?: { mode: 'temporal_coverage'; selectedEvidenceCount: number; sampled: boolean }
voiceCoverage?: KnowledgeVoiceCoverage
evidence?: Array<Pick<KnowledgeEvidence, 'timestamp' | 'sender' | 'sourceKind' | 'text'> & { messageRef: string }>
evidence?: QueryEvidenceItem[]
candidates?: Array<{ displayName: string; type: 'user' | 'group' }>
scope?: ResolvedCorpusScope
/**
* 证据来源:`wcdb` = 直接读源数据(会话概览的事实来源);`knowledge` = 派生索引。
* 派生索引可能滞后,故概览以源数据为准。
*/
origin?: 'wcdb' | 'knowledge'
}
export interface QueryCapabilitiesResponse {
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
}
/**
* 「跳转到原聊天」的锚点读取结果。
*
* `found: false` 是**有意义**的返回值,不是错误:它表示会话已经打开、窗口也加载了,
* 但目标消息不在窗口里(被清理 / 时间戳口径漂移)。UI 必须据此诚实提示,
* 而不是把"跳到了会话"说成"定位到了消息"。
*/
export interface MessagesAroundResult {
messages: Message[]
/** 窗口内精确匹配到目标消息(按规范化消息 id,不是按时间)。 */
found: boolean
/** 实际使用的窗口半径(秒);0 表示没有可用锚点时间、只做了兜底读取。 */
radiusSeconds: number
/** 窗口消息数超过单次上限被截断。 */
truncated: boolean
}
type TextContent = { type: 'text'; content: string }
type VoiceContent = { type: 'voice'; duration?: number }
type LocationContent = {
@@ -6,6 +6,7 @@ import {
SEARCH_CACHE_KEY,
buildSearchCacheKey
} from '../../src/renderer/src/components/search/searchUtils'
import { makeSearchResult } from './support/ai-search-fixtures'
const api = {
getSettings: vi.fn(),
@@ -352,12 +353,18 @@ describe('AISearchWorkspace cache privacy boundary', () => {
expect(input).toHaveValue('')
})
it('disables and guards analysis while the knowledge base is synchronizing', async () => {
it('keeps analysis available while the knowledge base is synchronizing (partial result + freshness warning)', async () => {
api.getKnowledgeStatus.mockResolvedValue({
state: 'syncing',
indexedMessageCount: 20,
indexedChunkCount: 4,
processedMessages: 20,
totalMessages: 100
})
api.runAiSearch.mockResolvedValue(makeSearchResult())
// 本地 Provider:本用例测的是"同步中能否提问",不是远程授权边界
// (远程授权边界由本文件另外的用例覆盖)。
api.getAiSearchProviderStatus.mockResolvedValue({ configured: true, requiresConsent: false })
const onNotice = vi.fn()
render(
<AISearchWorkspace
@@ -378,18 +385,24 @@ describe('AISearchWorkspace cache privacy boundary', () => {
/>
)
await userEvent.type(screen.getByRole('textbox'), '同步时不能分析')
const button = await screen.findByRole('button', { name: /同步中,暂不可分析/ })
expect(button).toBeDisabled()
const form = screen.getByRole('textbox').closest('form')
expect(form).not.toBeNull()
fireEvent.submit(form as HTMLFormElement)
await userEvent.type(screen.getByRole('textbox'), '同步时也要能分析')
// 等状态真的落地(同步中)再点,否则测的是"状态还没到"的竞态而不是产品行为。
await screen.findByText('Knowledge 可用 · 正在追新')
// 同步中**不允许**禁用提问:按钮必须可用,文案也不能变成"暂不可分析"。
const button = screen.getByRole('button', { name: /开始分析/ })
expect(button).toBeEnabled()
expect(screen.queryByRole('button', { name: /暂不可分析/ })).not.toBeInTheDocument()
await userEvent.click(button)
// 查询真的走到了检索(走完 consent 才会到这里),而不是被"请等待同步完成"挡回。
await waitFor(() => expect(api.getAiSearchProviderStatus).toHaveBeenCalled())
await waitFor(() =>
expect(onNotice).toHaveBeenCalledWith('知识库正在同步,请等待同步完成后再开始分析')
expect(onNotice).toHaveBeenCalledWith('知识库正在后台同步,本次结果可能未覆盖最新消息')
)
expect(api.getAiSearchProviderStatus).not.toHaveBeenCalled()
expect(api.runAiSearch).not.toHaveBeenCalled()
expect(onNotice).not.toHaveBeenCalledWith('知识库正在同步,请等待同步完成后再开始分析')
await waitFor(() => expect(api.runAiSearch).toHaveBeenCalledOnce())
expect(await screen.findByText('测试搜索答案')).toBeInTheDocument()
})
it('submits with Enter and keeps Shift+Enter available for a new line', async () => {
+9 -2
View File
@@ -71,8 +71,15 @@ describe('AISearchComposer', () => {
expect(props.onHistoryOpenChange).toHaveBeenCalledWith(false)
})
it('disables submission while Knowledge is syncing', () => {
it('keeps submission available while Knowledge is syncing and only warns about coverage', () => {
// 知识库同步是后台 / 可取消 / 可断点续传的,**不允许**因此禁止提问。
// 索引没追平时应由 coverage/freshness 契约如实标注覆盖范围。
renderComposer({ knowledgeSyncing: true })
expect(screen.getByRole('button', { name: /同步中,暂不可分析/ })).toBeDisabled()
const submit = screen.getByRole('button', { name: /开始分析/ })
expect(submit).toBeEnabled()
expect(screen.queryByRole('button', { name: /暂不可分析/ })).not.toBeInTheDocument()
expect(
screen.getByText('知识库后台同步中 · 仍可提问,答案会标注覆盖范围')
).toBeInTheDocument()
})
})
@@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useEvidenceCollection } from '../../src/renderer/src/components/search/hooks/useEvidenceCollection'
import type { EvidenceItem } from '../../src/renderer/src/components/search/searchTypes'
import { decodeMessageRef, encodeMessageRef } from '../../src/shared/local-query-api'
import {
aiSearchContact,
makeCacheRecord,
@@ -16,6 +17,9 @@ const makeEvidence = (count: number, offset = 0): EvidenceItem[] =>
return {
evidenceId: item.id,
contact: aiSearchContact,
// 稳定引用是"跳转到这一条"的唯一可靠身份:只给会话 + 秒级时间戳,
// 同一秒多条消息时根本定位不到目标。
messageRef: encodeMessageRef(aiSearchContact.md5, item.messageId),
message: {
id: item.messageId,
from: item.senderId,
@@ -168,14 +172,22 @@ describe('useEvidenceCollection', () => {
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'nearest' })
})
it('passes the selected Evidence contact and timestamp to the jump callback', () => {
it('passes the whole Evidence item — including its stable messageRef — to the jump callback', () => {
const { result } = renderHook(() => useEvidenceHarness())
const collection = makeEvidence(1)
act(() => result.current.setEvidenceResult(collection, collection))
act(() => result.current.jumpToEvidence(0))
expect(onOpenEvidence).toHaveBeenCalledWith(aiSearchContact, collection[0].message.createTime)
// 传整条证据而不是 `(contact, createTime)`:后者丢掉了稳定身份,跳转只能靠
// "会话 + 秒级时间戳"猜。引用必须能被还原成真实的 conversationId / messageId。
expect(onOpenEvidence).toHaveBeenCalledTimes(1)
const passed = onOpenEvidence.mock.calls[0][0] as EvidenceItem
expect(passed).toBe(collection[0])
expect(decodeMessageRef(passed.messageRef)).toEqual({
conversationId: aiSearchContact.md5,
messageId: collection[0].message.id
})
})
it('clears the previous request Evidence and selection before the next result is applied', () => {
@@ -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_HISTORY_KEY
} from '../../src/renderer/src/components/search/searchUtils'
import { decodeMessageRef } from '../../src/shared/local-query-api'
import {
aiSearchContact,
aiSearchGroup,
@@ -321,7 +322,7 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
expect(api.runAiSearch).toHaveBeenCalledOnce()
})
it('passes the selected Evidence contact and timestamp to the conversation jump callback', async () => {
it('passes the whole Evidence item with a decodable stable reference to the conversation jump callback', async () => {
const onOpenEvidence = vi.fn()
const item = makePipelineEvidence(1, aiSearchContact)
api.runAiSearch.mockResolvedValue(makeSearchResult({ evidence: [item] }))
@@ -330,7 +331,19 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
await screen.findByText('E1 · 发送者 1')
await userEvent.click(screen.getByRole('button', { name: '跳转到原聊天 ↗' }))
expect(onOpenEvidence).toHaveBeenCalledWith(aiSearchContact, Math.floor(item.timestamp / 1000))
// 跳转必须拿到**稳定身份**(messageRef),而不是只靠"会话 + 秒级时间戳"猜位置:
// 秒级时间无法定位同秒多条消息,也会因为 contact.md5 是合成 key 而跳错会话。
expect(onOpenEvidence).toHaveBeenCalledTimes(1)
const passed = onOpenEvidence.mock.calls[0][0] as {
messageRef?: string
contact: { md5: string }
}
expect(passed.contact.md5).toBe(aiSearchContact.md5)
expect(decodeMessageRef(passed.messageRef)).toEqual({
conversationId: aiSearchContact.md5,
messageId: item.messageId
})
})
it('clears the previous request Evidence before a refreshed request completes', async () => {
@@ -579,15 +592,52 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
expect(api.runAiSearch).toHaveBeenCalledTimes(2)
})
/**
* Knowledge 状态语义矩阵。
*
* 必须把 FRESHNESS(追到源数据最新)与 COMPLETENESS(这一遍 pass 的进度)分开表达,
* **不得**在落后时出现「已同步」。
*/
const STALE_INDEX_AT = new Date('2026-08-26T11:37:24+08:00').getTime()
const SOURCE_LATEST_AT = new Date('2026-09-11T11:57:24+08:00').getTime()
const makePass = (phase: 'full' | 'catchup' | 'backfill' | 'idle') => ({
phase,
cancellable: phase !== 'idle',
startedAt: SOURCE_LATEST_AT,
scannedMessages: 120,
indexedMessages: 20,
processedConversations: 3,
totalConversations: 10,
skippedConversations: 1,
catchupConversations: 4,
backfillConversations: 6,
backfillCompletedConversations: 2,
mainLoopLagMs: 4
})
it.each([
['unavailable', '未建立'],
['building', '建立中'],
['syncing', '增量同步'],
['ready', '已同步'],
['error', '异常']
] as const)('renders Knowledge state %s as %s', async (state, label) => {
['unavailable', '未建立', {}],
['building', '可用 · 正在补齐历史', {}],
['syncing', '可用 · 正在追新', { pass: makePass('catchup') }],
['syncing', '可用 · 正在补齐历史', { pass: makePass('full') }],
// backfill(补历史缺口)与 full(首次建库)对用户是同一件事:都不是"追最新"。
['syncing', '可用 · 正在补齐历史', { pass: makePass('backfill') }],
[
'ready',
'可用 · 待追新',
{ indexLatestAt: STALE_INDEX_AT, sourceLatestAt: SOURCE_LATEST_AT }
],
[
'ready',
'可用 · 已追至最新',
{ indexLatestAt: SOURCE_LATEST_AT, sourceLatestAt: SOURCE_LATEST_AT }
],
['cancelled', '可用 · 同步已取消', {}],
['error', '可用 · 更新失败', {}]
] as const)('renders Knowledge state %s as %s', async (state, label, overrides) => {
api.getKnowledgeStatus.mockResolvedValue({
...readyKnowledgeStatus,
...overrides,
state,
lastError: state === 'error' ? 'Worker 异常' : undefined
})
@@ -595,11 +645,51 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
expect(await screen.findAllByText(new RegExp(`Knowledge ${label}`))).not.toHaveLength(0)
if (state === 'error') expect(screen.getByText('Worker 异常')).toBeInTheDocument()
// 无论哪种状态,「已同步」这种把两件事混为一谈的笼统说法都不允许出现。
expect(screen.queryByText('已同步')).toBeNull()
})
it('never claims the index is up to date when freshness cannot be confirmed', async () => {
// 两个边界时间缺失(老库 / 运行态未上报)时无法判断是否追平。
// 安全侧是「待追新」——**不能**把"无法确认"说成「已追至最新」。
api.getKnowledgeStatus.mockResolvedValue({
...readyKnowledgeStatus,
indexLatestAt: null,
sourceLatestAt: null
})
renderWorkspace()
expect(await screen.findByText('Knowledge 可用 · 待追新')).toBeInTheDocument()
expect(screen.queryByText(/可用 · 已追至最新/)).toBeNull()
expect(screen.queryByText('已同步')).toBeNull()
})
it('does not claim usability when the derived index is unavailable', async () => {
// 没有分片却残留了历史计数时,不能说「可用 · …」——那是两句真话拼成的假话。
api.getKnowledgeStatus.mockResolvedValue({
...readyKnowledgeStatus,
state: 'unavailable',
indexedMessageCount: 0,
indexedChunkCount: 0
})
renderWorkspace()
expect(await screen.findByText('Knowledge 未建立')).toBeInTheDocument()
expect(screen.queryByText(/可用 · /)).toBeNull()
})
it('starts Knowledge indexing and reflects the returned status', async () => {
api.getKnowledgeStatus.mockResolvedValue({ ...readyKnowledgeStatus, state: 'unavailable' })
api.startKnowledgeIndex.mockResolvedValue({ ...readyKnowledgeStatus, state: 'syncing' })
api.getKnowledgeStatus.mockResolvedValue({
...readyKnowledgeStatus,
state: 'unavailable',
indexedMessageCount: 0,
indexedChunkCount: 0
})
api.startKnowledgeIndex.mockResolvedValue({
...readyKnowledgeStatus,
state: 'syncing',
pass: makePass('full')
})
const onNotice = vi.fn()
renderWorkspace({ onNotice })
await screen.findByText('Knowledge 未建立')
@@ -607,7 +697,7 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
expect(api.startKnowledgeIndex).toHaveBeenCalledOnce()
expect(onNotice).toHaveBeenCalledWith(expect.stringContaining('开始同步'))
expect(await screen.findByText('Knowledge 增量同步')).toBeInTheDocument()
expect(await screen.findByText('Knowledge 可用 · 正在补齐历史')).toBeInTheDocument()
})
it('unsubscribes Knowledge and Progress listeners on unmount', () => {
@@ -617,3 +707,213 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
expect(progressUnsubscribe).toHaveBeenCalledOnce()
})
})
/**
* Knowledge 卡片的布局契约(侧栏宽度不变,靠栅格而不是靠缩字号解决拥挤)。
*
* jsdom 不做布局计算,拿不到真实宽度,所以这里锁的是**布局所依赖的 DOM 契约**:
* - 五段结构(HEADER / CURRENT PASS / DATABASE STATUS / CURRENT / ACTION)各自的可见性;
* - 状态文案保持**单一文本节点**,换行只可能发生在「 · 」之后;
* - 数值行是「label + value」成对结构,nowrap 挂在 value 上;
* - 「取消同步」只在这一遍真的在跑时出现,且带不会被压扁的类。
*
* 刻意不写整份 CSS 快照:那种断言在调样式时全是噪声,也证明不了布局真的对。
*/
describe('Knowledge card layout contract', () => {
const STALE_INDEX_AT = new Date('2026-08-26T11:37:24+08:00').getTime()
const SOURCE_LATEST_AT = new Date('2026-09-11T11:57:24+08:00').getTime()
const makePass = (phase: 'full' | 'catchup' | 'backfill' | 'idle') => ({
phase,
cancellable: phase !== 'idle',
startedAt: SOURCE_LATEST_AT,
scannedMessages: 2_400,
indexedMessages: 1_200,
processedConversations: 400,
totalConversations: 4_800,
skippedConversations: 200,
catchupConversations: 4,
backfillConversations: 6,
backfillCompletedConversations: 2,
mainLoopLagMs: 0
})
const status = (state: string, extra: Record<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,
AiSearchTimeRange
} from '../../src/shared/ai-search'
import type { KnowledgeRuntimeStatus } from '../../src/shared/knowledge'
import type { KnowledgePassProgress, KnowledgeRuntimeStatus } from '../../src/shared/knowledge'
import type { Contact } from '../../src/shared/types'
import { decodeMessageRef, encodeMessageRef } from '../../src/shared/local-query-api'
import {
contactLabel,
formatBytes,
@@ -249,13 +250,54 @@ describe('AI Search workspace pure formatters', () => {
)
})
it('keeps every knowledge runtime state label unchanged', () => {
it('labels Knowledge states with the mandated freshness vocabulary', () => {
const usable = (
state: KnowledgeRuntimeStatus['state'],
extra: Partial<KnowledgeRuntimeStatus> = {}
): KnowledgeRuntimeStatus => ({
...makeKnowledgeStatus(state),
indexedMessageCount: 20,
indexedChunkCount: 4,
...extra
})
const pass = (phase: 'full' | 'catchup' | 'idle'): KnowledgePassProgress => ({
phase,
cancellable: phase !== 'idle',
startedAt: 1_700_000_000_000,
scannedMessages: 120,
indexedMessages: 20,
processedConversations: 3,
totalConversations: 10,
skippedConversations: 1,
mainLoopLagMs: 4
})
expect(knowledgeStateLabel(null)).toBe('读取中')
// 一个分片都没有:这不是"落后",是"还没建立"。
expect(knowledgeStateLabel(makeKnowledgeStatus('unavailable'))).toBe('未建立')
expect(knowledgeStateLabel(makeKnowledgeStatus('building'))).toBe('建立中')
expect(knowledgeStateLabel(makeKnowledgeStatus('syncing'))).toBe('增量同步')
expect(knowledgeStateLabel(makeKnowledgeStatus('ready'))).toBe('已同步')
expect(knowledgeStateLabel(makeKnowledgeStatus('error'))).toBe('异常')
expect(knowledgeStateLabel(makeKnowledgeStatus('building'))).toBe('正在建立')
// ready ≠ fresh:这两件事必须在文案里分开。
expect(knowledgeStateLabel(usable('building'))).toBe('可用 · 正在补齐历史')
expect(knowledgeStateLabel(usable('syncing', { pass: pass('catchup') }))).toBe(
'可用 · 正在追新'
)
expect(knowledgeStateLabel(usable('syncing', { pass: pass('full') }))).toBe(
'可用 · 正在补齐历史'
)
expect(
knowledgeStateLabel(usable('ready', { indexLatestAt: 1_700_000_000_000, sourceLatestAt: 1_700_000_999_000 }))
).toBe('可用 · 待追新')
expect(
knowledgeStateLabel(usable('ready', { indexLatestAt: 1_700_000_999_000, sourceLatestAt: 1_700_000_999_000 }))
).toBe('可用 · 已追至最新')
expect(knowledgeStateLabel(usable('cancelled'))).toBe('可用 · 同步已取消')
expect(knowledgeStateLabel(usable('error'))).toBe('可用 · 更新失败')
// 笼统的「已同步」把"能查"和"追平"混成一句话,已被彻底移除。
for (const state of ['unavailable', 'building', 'syncing', 'ready', 'cancelled', 'error'] as const) {
expect(knowledgeStateLabel(usable(state))).not.toBe('已同步')
}
// 派生库不可查询时不允许冒充"可用"(残留的历史计数不算数)。
expect(knowledgeStateLabel(usable('unavailable'))).toBe('未建立')
})
it('keeps the existing contact label fallback order', () => {
@@ -287,6 +329,8 @@ describe('AI Search pipeline evidence mapping', () => {
evidenceId: 'E1',
sourceKind: 'text',
contact: aiSearchContact,
// Legacy 路径同样要带稳定引用:否则跳转只能靠"会话 + 秒级时间戳"猜。
messageRef: encodeMessageRef(aiSearchContact.md5, item.messageId),
message: {
id: 'message-1',
from: 'sender-1',
@@ -299,6 +343,10 @@ describe('AI Search pipeline evidence mapping', () => {
createTime: Math.floor(item.timestamp / 1_000)
}
})
expect(decodeMessageRef(mapped.messageRef)).toEqual({
conversationId: aiSearchContact.md5,
messageId: item.messageId
})
})
it('creates the existing group fallback and voice presentation for an unloaded conversation', () => {
@@ -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)
})
/**
* 取消之后 `run_state` 不允许留下一个假的 `indexing`,而且已提交的分片必须保留
* (取消 ≠ 回滚),下一次索引从断点继续。
*/
it('records a cancelled pass as cancelled and keeps the committed chunks queryable', async () => {
const root = makeRoot()
// 两个会话:取消发生在第二个会话内部,于是第一个会话必须已经提交。
// (生产里索引是 per-conversation 事务,取消 ≠ 回滚。)
const committed = createSyntheticConversation(
FIXTURE_ACCOUNT_A,
'conversation-a',
0,
300,
'mixed'
)
const interrupted = createSyntheticConversation(
FIXTURE_ACCOUNT_A,
'conversation-b',
10_000,
2_000,
'mixed'
)
const controller = new AbortController()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
const cancelled = await store.index(
{ conversations: [committed, interrupted], chunker: DEFAULT_KNOWLEDGE_CHUNKER },
controller.signal,
(progress) => {
if (progress.conversationId === 'conversation-b' && progress.processedMessages >= 900) {
controller.abort()
}
}
)
expect(cancelled.cancelled).toBe(true)
// 取消是真实的终态,不能被当成"还在跑"。
const status = store.getRuntimeStatus()
expect(status.state).toBe('cancelled')
expect(status.state).not.toBe('indexing')
// 已经提交的会话仍然可查 —— 取消不等于回滚。
const evidence = store.search({ accountId: FIXTURE_ACCOUNT_A, text: '本地知识库', limit: 5 })
expect(evidence.length).toBeGreaterThan(0)
expect(new Set(evidence.map((item) => item.conversationId))).toEqual(
new Set(['conversation-a'])
)
store.close()
})
it('does not report an interrupted pass as an unusable index', async () => {
const root = makeRoot()
const source = createSyntheticConversation(
FIXTURE_ACCOUNT_A,
'conversation-a',
0,
200,
'mixed'
)
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
await store.index({ conversations: [source], chunker: DEFAULT_KNOWLEDGE_CHUNKER })
expect(store.getRuntimeStatus().state).toBe('ready')
store.close()
// 模拟进程被杀:派生库里分片齐全,但 run_state 残留 'indexing'。
const databasePath = getKnowledgeDatabasePath(root, FIXTURE_ACCOUNT_A)
const raw = new DatabaseSync(databasePath)
raw
.prepare(`UPDATE knowledge_meta SET value = 'indexing' WHERE key = 'run_state'`)
.run()
raw.close()
const reopened = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
// 「可查询」不能因为一次中断残留就变成「不可用」。
expect(reopened.getRuntimeStatus().state).toBe('ready')
reopened.close()
})
it('uses a bounded exact fallback for two-character Chinese queries with the trigram profile', async () => {
const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
@@ -352,6 +428,49 @@ describe('knowledge sqlite', () => {
store.close()
})
it('把 per-conversation 的源侧覆盖边界当作索引覆盖口径(meta / high_water_time 只作回退)', async () => {
const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
const conversation = createSyntheticConversation(FIXTURE_ACCOUNT_A, 'coverage', 0, 4, 'mixed')
const lastCreateTime = Math.max(...conversation.messages.map((message) => message.createTime))
await store.index({ conversations: [conversation], chunker: DEFAULT_KNOWLEDGE_CHUNKER })
// 既没有 per-conversation 源侧边界、也没有完整 pass 口径时,退化为
// "最新被索引的消息时间"(只会偏旧、不会冒充更新)。
expect(store.getRuntimeStatus().indexLatestAt).toBe(lastCreateTime)
await store.index({
conversations: [conversation],
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
sourceMessageCount: conversation.messages.length,
// 源数据边界可以比"被索引建模的最新消息"更新(例如最新一条是不可建模的图片)。
sourceLatestAt: lastCreateTime + 60_000
})
const status = store.getRuntimeStatus()
expect(status.indexLatestAt).toBe(lastCreateTime + 60_000)
// 派生库自己看不到源数据,sourceLatestAt 由 KnowledgeSearchService 填。
expect(status.sourceLatestAt).toBeNull()
// per-conversation 的源侧边界一旦存在就是**权威口径**:它是每个成功处理的会话
// 立刻持久化的聚合值,增量 pass 也能推进它。而 `source_latest_at` meta 只在
// 「整遍零跳过」时才写 —— 增量世界里这让它永久冻结(冻结值落后真实 checkpoint,
// 导致 isKnowledgeFresh() 恒为 false)。
await store.index({
conversations: [{ ...conversation, sourceHighWaterTime: lastCreateTime + 180_000 }],
chunker: DEFAULT_KNOWLEDGE_CHUNKER
})
expect(store.getRuntimeStatus().indexLatestAt).toBe(lastCreateTime + 180_000)
// checkpoint 单调:一个"看起来更旧"的源侧边界不得让覆盖率回退,
// 否则已经追到最新的会话会被重新打回"有新消息",每遍都白读。
await store.index({
conversations: [{ ...conversation, sourceHighWaterTime: lastCreateTime + 1_000 }],
chunker: DEFAULT_KNOWLEDGE_CHUNKER
})
expect(store.getRuntimeStatus().indexLatestAt).toBe(lastCreateTime + 180_000)
store.close()
})
it('keeps conversation, sender and time filters when a participant question has no topic terms', async () => {
const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
+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'
describe('LocalQueryApiService', () => {
const knowledge = { search: vi.fn(async () => ({ state: 'ready', evidence: [{ conversationId: 'md5-bobo', messageId: 'm1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }], conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true }, voiceCoverage: undefined })) } as any
const knowledge = {
search: vi.fn(async () => ({ state: 'ready', evidence: [{ conversationId: 'md5-bobo', messageId: 'm1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }], conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true }, voiceCoverage: undefined })),
requestCatchUp: vi.fn(() => ({ triggered: false, inProgress: false })),
waitForIndexingComplete: vi.fn(async () => false),
lastPassDurationMs: vi.fn(() => 0),
beginInteractiveQuery: vi.fn(),
endInteractiveQuery: vi.fn()
} as any
/** 固定时钟:requestedEnd 由它决定。 */
const NOW = new Date('2026-09-09T12:00:00+08:00').getTime()
const DAY = 24 * 60 * 60 * 1000
let service: LocalQueryApiService
beforeEach(() => { vi.clearAllMocks(); if (!fixture.contacts.some((contact) => contact.md5 === 'md5-bobo-2')) fixture.contacts.push({ m_nsUsrName: 'wxid-bobo-2', m_nsNickName: 'BOBO', md5: 'md5-bobo-2', type: 'user' }); service = new LocalQueryApiService(knowledge, () => new Date('2026-09-09T12:00:00+08:00')) })
beforeEach(() => {
vi.clearAllMocks()
// mockReset 会清掉 once 队列与实现:避免上一个用例残留的队列被下一个用例消费。
knowledge.search.mockReset()
knowledge.search.mockImplementation(async () => ({
state: 'ready',
evidence: [{ conversationId: 'md5-bobo', messageId: 'm1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }],
conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true },
voiceCoverage: undefined
}))
knowledge.requestCatchUp.mockReset()
knowledge.requestCatchUp.mockReturnValue({ triggered: false, inProgress: false })
knowledge.waitForIndexingComplete.mockReset()
knowledge.waitForIndexingComplete.mockResolvedValue(false)
knowledge.lastPassDurationMs.mockReset()
knowledge.lastPassDurationMs.mockReturnValue(0)
if (!fixture.contacts.some((contact) => contact.md5 === 'md5-bobo-2')) fixture.contacts.push({ m_nsUsrName: 'wxid-bobo-2', m_nsNickName: 'BOBO', md5: 'md5-bobo-2', type: 'user' })
service = new LocalQueryApiService(knowledge, () => new Date('2026-09-09T12:00:00+08:00'))
})
it('publishes capability contract and resolves previous month', async () => {
expect(service.capabilities().tools.query_messages.messageTypes).toContain('file')
@@ -63,22 +91,333 @@ describe('LocalQueryApiService', () => {
expect(searchedRef).toBe(queriedRef)
await expect(service.context({ messageRef: searchedRef!, before: 0, after: 0 })).resolves.toMatchObject({ status: 'completed' })
knowledge.search.mockResolvedValueOnce({ state: 'ready', evidence: [{ conversationId: 'md5-bobo', messageId: 'local:m1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }], conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true }, voiceCoverage: undefined })
// 概览直读 WCDB,不再消费派生索引 → 这里不再排队 knowledge mock(残留队列会污染后续用例)。
const overview = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'all' } })
const overviewRef = overview.evidence?.[0]?.messageRef
expect(overviewRef).toBe(queriedRef)
await expect(service.context({ messageRef: overviewRef!, before: 0, after: 0 })).resolves.toMatchObject({ status: 'completed' })
})
it('sorts overview evidence while preserving the selected set and exposes sampling', async () => {
it('会话概览以 WCDB 为事实来源,并在派生索引为空时不返回「0 + complete」(P0 回归)', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValueOnce({ state: 'ready', evidence: [
{ conversationId: 'md5-bobo', messageId: 'late', timestamp: 300, sender: 'BOBO', sourceKind: 'text', text: 'late' },
{ conversationId: 'md5-bobo', messageId: 'early', timestamp: 100, sender: 'BOBO', sourceKind: 'text', text: 'early' }
], conversationRetrieval: { totalMessages: 82, chunkCount: 4, complete: true }, voiceCoverage: undefined })
// 陈旧索引的真实形态:索引对这一会话在范围内 0 行,且自称 complete。
knowledge.search.mockResolvedValue({
state: 'ready',
evidence: [],
conversationRetrieval: { totalMessages: 0, chunkCount: 0, complete: true },
voiceCoverage: undefined
})
const result = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' } })
expect(result.sourceCoverage).toEqual({ state: 'complete', sourceMessageCount: 82 })
expect(result.selection).toEqual({ mode: 'temporal_coverage', selectedEvidenceCount: 2, sampled: true })
expect(result.evidence?.map((item) => item.timestamp)).toEqual([100, 300])
expect(result).toMatchObject({ status: 'completed', origin: 'wcdb', sourceMessageCount: 2, evidenceCount: 2 })
expect(result.coverage).toEqual({ state: 'complete' })
expect(result.evidence?.map((item) => item.messageRef)).toHaveLength(2)
// 概览不再依赖派生索引(索引可能滞后于 WCDB)。
expect(knowledge.search).not.toHaveBeenCalled()
})
it('会话概览保留时间片代表证据,并如实标注覆盖状态', async () => {
fixture.contacts.splice(1)
const result = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' } })
expect(result.sourceCoverage).toEqual({ state: 'complete', sourceMessageCount: 2 })
expect(result.evidence?.map((item) => item.timestamp)).toEqual(
[...fixture.messages].map((message) => message.createTime * 1000)
)
expect(result.selection?.mode).toBe('temporal_coverage')
})
it('scope=groups 只检索群会话,并把群名与成员写进证据', async () => {
fixture.contacts.splice(1)
fixture.contacts.push({ m_nsUsrName: 'wxid-group@chatroom', m_nsNickName: 'TraceMemo 交流群', md5: 'md5-group', type: 'group' } as never)
knowledge.search.mockResolvedValue({
state: 'ready',
evidence: [{ conversationId: 'md5-group', messageId: 'g1', timestamp: 5, sender: '张三', sourceKind: 'text', text: '最近开始健身了' }],
voiceCoverage: undefined
})
const result = await service.search({ timeRange: { kind: 'all' }, query: '健身', scope: { kind: 'groups' } })
expect(knowledge.search).toHaveBeenCalledWith(expect.objectContaining({ conversationIds: ['md5-group'] }))
expect(result.scope).toEqual({ kind: 'groups', conversationCount: 1 })
expect(result.evidence?.[0]).toMatchObject({
conversationName: 'TraceMemo 交流群',
conversationType: 'group',
sender: '张三'
})
})
it('scope=all 省略 target 时按全局检索(不传 conversationIds)', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValue({ state: 'ready', evidence: [], voiceCoverage: undefined })
const result = await service.search({ timeRange: { kind: 'all' }, query: '健身', scope: { kind: 'all' } })
expect(knowledge.search).toHaveBeenCalledWith(expect.objectContaining({ conversationIds: undefined }))
// 跨会话检索不宣称"完整":派生索引不是完整性权威。
expect(result.coverage).toEqual({ state: 'partial' })
})
it('scope 内的 target 越界会被结构化拒绝(而不是悄悄扩大范围)', async () => {
fixture.contacts.splice(1)
const result = await service.search({
target: { query: 'BOBO' },
timeRange: { kind: 'all' },
query: '健身',
scope: { kind: 'groups' }
})
expect(result).toMatchObject({
status: 'invalid_tool_arguments',
field: 'target',
constraint: 'target_outside_scope',
actual: 'BOBO'
})
expect(knowledge.search).not.toHaveBeenCalled()
})
it('scope=contact 只允许该单聊会话,未知会话会被拒绝', async () => {
fixture.contacts.splice(1)
const allowed = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, scope: { kind: 'contact', conversationId: 'md5-bobo' } })
expect(allowed.status).toBe('completed')
const rejected = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, scope: { kind: 'contact', conversationId: 'md5-unknown' } })
expect(rejected.status).toBe('scope_conversation_not_found')
})
it('会话概览在范围内有多个会话且未指定 target 时要求指定 target', async () => {
fixture.contacts.splice(1)
const result = await service.overview({ timeRange: { kind: 'all' }, scope: { kind: 'groups' } })
expect(result).toMatchObject({ status: 'invalid_tool_arguments', constraint: 'target_required_for_scope' })
})
describe('freshness contract(索引落后时不得出现 stale complete negative)', () => {
it('交互检索全程标记为前台:后台索引必须让路(否则查询会被 pass 拖慢)', async () => {
fixture.contacts.splice(1)
const order: string[] = []
knowledge.beginInteractiveQuery.mockImplementation(() => order.push('begin'))
knowledge.endInteractiveQuery.mockImplementation(() => order.push('end'))
knowledge.search.mockImplementation(async () => {
order.push('search')
return { state: 'ready', evidence: [], indexLatestAt: NOW - 30 * 1000, sourceLatestAt: NOW - 1000, voiceCoverage: undefined }
})
await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(order).toEqual(['begin', 'search', 'end'])
expect(knowledge.beginInteractiveQuery).toHaveBeenCalledTimes(1)
expect(knowledge.endInteractiveQuery).toHaveBeenCalledTimes(1)
})
it('同一次查询的多个 probe 共用 retrieval session(避免重复读群成员快照)', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValue({ state: 'ready', indexLatestAt: NOW - 30 * 1000, sourceLatestAt: NOW - 1000, evidence: [], voiceCoverage: undefined })
await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身', variants: ['锻炼', '跑步'] })
expect(knowledge.search).toHaveBeenCalledTimes(3)
const sessions = new Set(knowledge.search.mock.calls.map((call: unknown[]) => (call[0] as { retrievalSessionId?: string }).retrievalSessionId))
expect(sessions.size).toBe(1)
expect([...sessions][0]).toEqual(expect.any(String))
})
const staleIndex = (): Record<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 { parsePocQuestion } from '../../src/main/query-agent-poc-cli'
import { parsePocInvocation, parsePocQuestion } from '../../src/main/query-agent-poc-cli'
describe('Query Agent POC CLI 参数解析', () => {
it('移除开头的分隔符 --', () => {
@@ -34,3 +34,40 @@ describe('Query Agent POC CLI 参数解析', () => {
expect(argv).toEqual(['--', '问题'])
})
})
describe('parsePocInvocation — 诊断模式与搜索范围', () => {
it('无 --tool 时是自然语言模式,并把 --scope 从问题里剥掉', () => {
expect(parsePocInvocation(['我和 BOBO 第一次聊了什么'])).toMatchObject({
kind: 'question',
question: '我和 BOBO 第一次聊了什么'
})
const scoped = parsePocInvocation([
'--scope',
'{"kind":"groups"}',
'最近谁聊过健身'
])
expect(scoped).toMatchObject({ kind: 'question', question: '最近谁聊过健身' })
if (scoped.kind !== 'question') throw new Error('unreachable')
expect(scoped.scope).toEqual({ kind: 'groups' })
})
it('裸工具模式把 scope 合并进工具参数(Host 注入,LLM 提供不了)', () => {
const invocation = parsePocInvocation([
'--tool',
'search_messages',
'--args',
'{"timeRange":{"kind":"all"},"query":"健身"}',
'--scope',
'{"kind":"groups"}'
])
if (invocation.kind !== 'tool') throw new Error('unreachable')
expect(invocation.toolName).toBe('search_messages')
expect(invocation.args).toMatchObject({ query: '健身', scope: { kind: 'groups' } })
})
it('非法 scope / 缺少 conversationId 会明确报错', () => {
expect(() => parsePocInvocation(['--scope', '{"kind":"nope"}', 'x'])).toThrow(/scope.kind/)
expect(() => parsePocInvocation(['--scope', '{"kind":"contact"}', 'x'])).toThrow(/conversationId/)
expect(() => parsePocInvocation(['--scope', 'not-json', 'x'])).toThrow(/合法 JSON/)
})
})
+2 -2
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import { formatProviderDiagnostics, formatTiming } from '../../src/main/query-agent-poc-report'
import type { QueryAgentPocResult } from '../../src/main/services/query-agent-poc-service'
import type { QueryAgentResult } from '../../src/main/services/query-agent-service'
function result(patch: Partial<QueryAgentPocResult> = {}): QueryAgentPocResult {
function result(patch: Partial<QueryAgentResult> = {}): QueryAgentResult {
return {
question: 'q',
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 { 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 {
return {
@@ -12,10 +12,10 @@ function provider(responses: Array<Awaited<ReturnType<QueryAgentProvider['chatWi
const MONTH_TEXT = '上个月'
const MONTH_QUESTION = '上个月 BOBO 有没有给我发过文件'
describe('QueryAgentPocService', () => {
describe('QueryAgentService', () => {
it('runs a bounded model -> tool -> model loop and records sanitized trace', async () => {
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 1, messages: [{ messageRef: 'secret-ref' }] }))
const service = new QueryAgentPocService(provider([
const service = new QueryAgentService(provider([
{ success: true, toolCalls: [{ id: 'call-1', name: 'query_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, temporalBasis: { kind: 'none' }, limit: 1 }) }] },
{ success: true, data: '第一条消息是图片。' }
]), execute)
@@ -24,7 +24,13 @@ describe('QueryAgentPocService', () => {
expect(result.modelCallCount).toBe(2)
expect(result.toolCallCount).toBe(1)
expect(result.traces[0]).toMatchObject({ toolName: 'query_messages', status: 'completed', resultCount: 1 })
expect(JSON.stringify(result)).not.toContain('secret-ref')
// trace / input 必须保持脱敏(opaque messageRef 不出现在 trace 里)。
expect(JSON.stringify(result.traces)).not.toContain('secret-ref')
// 证据是 ADDITIVE 的展示契约:只有 evidence[] 携带 opaque messageRef,且不含会话身份。
expect(result.evidence).toEqual([
expect.objectContaining({ messageRef: 'secret-ref', source: 'query_messages' })
])
expect(JSON.stringify(result.evidence)).not.toMatch(/wxid|md5|conversationId/)
})
it('presents planning boundaries and remaining budget with tool results', async () => {
@@ -39,7 +45,7 @@ describe('QueryAgentPocService', () => {
{ success: true, toolCalls: [{ id: 'call-1', name: 'search_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, queries: ['topic'] }) }] },
{ success: true, data: '根据这条证据可以回答。' }
])
await new QueryAgentPocService(configuredProvider, execute).run('查找相关记录')
await new QueryAgentService(configuredProvider, execute).run('查找相关记录')
const calls = vi.mocked(configuredProvider.chatWithTools).mock.calls
const firstMessages = calls[0]?.[0] || []
expect(String(firstMessages[0]?.content)).toContain('Evidence 是否已经足以')
@@ -56,7 +62,7 @@ describe('QueryAgentPocService', () => {
{ success: true, toolCalls: [{ id: 'call-1', name: 'query_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, temporalBasis: { kind: 'none' }, limit: 1 }) }] },
{ success: true, data: '完成' }
])
await new QueryAgentPocService(configuredProvider, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('第一条消息')
await new QueryAgentService(configuredProvider, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('第一条消息')
expect(vi.mocked(configuredProvider.chatWithTools).mock.calls[1]?.[1]).toEqual([])
})
@@ -67,7 +73,7 @@ describe('QueryAgentPocService', () => {
{ success: true, toolCalls: [{ id: 'call-2', name: 'conversation_overview', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' } }) }] },
{ success: true, data: '完成' }
])
const result = await new QueryAgentPocService(configuredProvider, execute).run('第一条消息')
const result = await new QueryAgentService(configuredProvider, execute).run('第一条消息')
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1]).toMatchObject({ toolName: 'conversation_overview', status: 'invalid_tool_arguments' })
const thirdCallMessages = vi.mocked(configuredProvider.chatWithTools).mock.calls[2]?.[0] || []
@@ -78,7 +84,7 @@ describe('QueryAgentPocService', () => {
it('rejects unknown tools and stops after five calls', async () => {
const execute = vi.fn(async () => ({ status: 'completed' }))
const responses = Array.from({ length: 6 }, () => ({ success: true, toolCalls: [{ id: 'x', name: 'unknown', arguments: '{}' }] }))
const result = await new QueryAgentPocService(provider(responses), execute).run('test')
const result = await new QueryAgentService(provider(responses), execute).run('test')
expect(result.toolCallCount).toBe(5)
expect(result.traces.every((trace) => trace.status === 'invalid_tool_arguments')).toBe(true)
expect(result.error).toContain('最大工具调用次数')
@@ -87,14 +93,14 @@ describe('QueryAgentPocService', () => {
it('clarifies unavailable configuration without making a model call', async () => {
const configuredProvider = provider([], false)
const result = await new QueryAgentPocService(configuredProvider, vi.fn()).run('test')
const result = await new QueryAgentService(configuredProvider, vi.fn()).run('test')
expect(result.error).toContain('尚未配置')
expect(configuredProvider.chatWithTools).not.toHaveBeenCalled()
})
it('maps LLM queries[] to the Local Query API query + variants contract', async () => {
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 0 }))
const valid = await new QueryAgentPocService(provider([
const valid = await new QueryAgentService(provider([
{ success: true, toolCalls: [{ id: 'valid', name: 'search_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, queries: ['答应', '承诺', '保证', '说好'] }) }] },
{ success: true, data: 'done' }
]), execute).run('test')
@@ -105,7 +111,7 @@ describe('QueryAgentPocService', () => {
expect(execute.mock.calls[0][1]).not.toHaveProperty('queries')
execute.mockClear()
const invalid = await new QueryAgentPocService(provider([
const invalid = await new QueryAgentService(provider([
{ success: true, toolCalls: [{ id: 'invalid', name: 'search_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, queries: ['1', '2', '3', '4', '5'] }) }] },
{ success: true, data: '修正后完成' }
]), execute).run('test')
@@ -233,7 +239,7 @@ describe('QueryAgent zero-result limited retry', () => {
it('keeps search_messages available once after a zero-evidence search', async () => {
const execute = vi.fn(emptySearch)
const configured = provider([searchCall('c1', ['答应']), { success: true, data: '没有找到相关证据。' }])
await new QueryAgentPocService(configured, execute).run('找承诺')
await new QueryAgentService(configured, execute).run('找承诺')
expect(vi.mocked(configured.chatWithTools).mock.calls[1]?.[1].map((tool) => tool.function.name)).toEqual(['search_messages'])
const toolMessage = vi.mocked(configured.chatWithTools).mock.calls[1]?.[0].find((message) => message.role === 'tool')
const presented = JSON.parse(String(toolMessage?.content)) as Record<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 () => {
const execute = vi.fn(emptySearch)
const configured = provider([searchCall('c1', ['答应']), searchCall('c2', ['公积金']), { success: true, data: '仍然没有。' }])
const result = await new QueryAgentPocService(configured, execute).run('找承诺')
const result = await new QueryAgentService(configured, execute).run('找承诺')
expect(execute).toHaveBeenCalledTimes(2)
expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'completed'])
expect(vi.mocked(configured.chatWithTools).mock.calls[2]?.[1]).toEqual([])
@@ -254,7 +260,7 @@ describe('QueryAgent zero-result limited retry', () => {
it('rejects an identical search retry instead of spending the budget again', async () => {
const execute = vi.fn(emptySearch)
const configured = provider([searchCall('c1', ['答应']), searchCall('c2', ['答应']), { success: true, data: '没有找到。' }])
const result = await new QueryAgentPocService(configured, execute).run('找承诺')
const result = await new QueryAgentService(configured, execute).run('找承诺')
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' })
expect(vi.mocked(configured.chatWithTools).mock.calls[2]?.[1]).toEqual([])
@@ -265,7 +271,7 @@ describe('QueryAgent zero-result limited retry', () => {
it('treats a reordered identical probe set as an identical retry', async () => {
const execute = vi.fn(emptySearch)
const configured = provider([searchCall('c1', ['答应', '承诺']), searchCall('c2', ['承诺', '答应']), { success: true, data: 'x' }])
const result = await new QueryAgentPocService(configured, execute).run('找承诺')
const result = await new QueryAgentService(configured, execute).run('找承诺')
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1].status).toBe('invalid_tool_arguments')
})
@@ -273,14 +279,14 @@ describe('QueryAgent zero-result limited retry', () => {
it('keeps the efficient path when the first search already returned evidence', async () => {
const execute = vi.fn(async () => ({ status: 'completed', evidenceCount: 2, evidence: [{ messageRef: 'ref', text: 't' }] }))
const configured = provider([searchCall('c1', ['答应']), { success: true, data: 'ok' }])
await new QueryAgentPocService(configured, execute).run('找承诺')
await new QueryAgentService(configured, execute).run('找承诺')
expect(vi.mocked(configured.chatWithTools).mock.calls[1]?.[1].map((tool) => tool.function.name)).toEqual(['message_context'])
})
it('rejects an identical query_messages retry', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'previous_month' }), queryCall('c2', { kind: 'previous_month' }), { success: true, data: 'x' }])
const result = await new QueryAgentPocService(configured, execute).run(MONTH_QUESTION)
const result = await new QueryAgentService(configured, execute).run(MONTH_QUESTION)
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1].status).toBe('invalid_tool_arguments')
})
@@ -288,7 +294,7 @@ describe('QueryAgent zero-result limited retry', () => {
it('keeps the efficient stop when the exact query already returned messages', async () => {
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 3, messages: [] }))
const configured = provider([queryCall('c1'), { success: true, data: 'ok' }])
await new QueryAgentPocService(configured, execute).run(MONTH_QUESTION)
await new QueryAgentService(configured, execute).run(MONTH_QUESTION)
expect(vi.mocked(configured.chatWithTools).mock.calls[1]?.[1]).toEqual([])
})
@@ -302,7 +308,7 @@ describe('QueryAgent zero-result limited retry', () => {
queryCall('c5', { kind: 'this_year' }),
queryCall('c6', { kind: 'this_month' })
]
const result = await new QueryAgentPocService(provider(responses), execute).run(MONTH_QUESTION)
const result = await new QueryAgentService(provider(responses), execute).run(MONTH_QUESTION)
expect(result.toolCallCount).toBe(5)
expect(result.error).toContain('最大工具调用次数')
})
@@ -317,7 +323,7 @@ describe('QueryAgent zero-result limited retry', () => {
toolCalls: [{ id, name: 'query_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: range, temporalBasis: { kind: 'constraint', sourceText: '8 月' }, messageTypes }) }]
})
const configured = provider([call('c1', ['file']), call('c2', ['text']), { success: true, data: '找到。' }])
const result = await new QueryAgentPocService(configured, execute).run('今年 8 月有没有给我发过文件')
const result = await new QueryAgentService(configured, execute).run('今年 8 月有没有给我发过文件')
expect(execute).toHaveBeenCalledTimes(2)
expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'completed'])
// the explicit range is preserved on the retry — only the non-temporal condition changed
@@ -346,7 +352,7 @@ describe('QueryAgent temporal basis policy', () => {
it('requires temporalBasis on query_messages', async () => {
const execute = vi.fn(emptyQuery)
const call = { success: true as const, toolCalls: [{ id: 'c1', name: 'query_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' } }) }] }
const result = await new QueryAgentPocService(provider([call, { success: true, data: 'x' }]), execute).run(CONSTRAINT_Q)
const result = await new QueryAgentService(provider([call, { success: true, data: 'x' }]), execute).run(CONSTRAINT_Q)
expect(execute).not.toHaveBeenCalled()
expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' })
})
@@ -354,7 +360,7 @@ describe('QueryAgent temporal basis policy', () => {
it('exposes the declared temporalBasis on the trace and never forwards it to the Local Query API', async () => {
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 1, messages: [] }))
const configured = provider([queryCall('c1', { kind: 'last_7_days' }, HINT), { success: true, data: 'ok' }])
const result = await new QueryAgentPocService(configured, execute).run(HINT_Q)
const result = await new QueryAgentService(configured, execute).run(HINT_Q)
expect(result.traces[0].temporalBasis).toEqual({ kind: 'recall_hint', sourceText: '前阵子' })
expect(result.traces[0].input).not.toHaveProperty('temporalBasis')
expect(execute.mock.calls[0][1]).not.toHaveProperty('temporalBasis')
@@ -363,7 +369,7 @@ describe('QueryAgent temporal basis policy', () => {
it('rejects a sourceText that is not literally in the user question', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'previous_month' }, { kind: 'constraint', sourceText: '去年冬天' }), { success: true, data: 'x' }])
const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q)
const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q)
expect(execute).not.toHaveBeenCalled()
expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' })
expect(JSON.stringify(toolMessageAt(configured, 1))).toContain('source_not_in_question')
@@ -372,7 +378,7 @@ describe('QueryAgent temporal basis policy', () => {
it('requires sourceText for constraint and recall_hint', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'previous_month' }, { kind: 'constraint' }), { success: true, data: 'x' }])
const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q)
const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q)
expect(execute).not.toHaveBeenCalled()
expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' })
})
@@ -380,7 +386,7 @@ describe('QueryAgent temporal basis policy', () => {
it('rejects sourceText when kind is none', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'all' }, { kind: 'none', sourceText: '上个月' }), { success: true, data: 'x' }])
const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q)
const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q)
expect(execute).not.toHaveBeenCalled()
expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' })
expect(JSON.stringify(toolMessageAt(configured, 1))).toContain('forbidden_for_none')
@@ -392,7 +398,7 @@ describe('QueryAgent temporal basis policy', () => {
const sourceText = '8 月 1 日到 9 月 1 日'
const range = { kind: 'absolute', startTime: '2026-08-01T00:00:00+08:00', endTime: '2026-09-01T00:00:00+08:00' }
const configured = provider([queryCall('c1', range, { kind: 'constraint', sourceText }), { success: true, data: 'ok' }])
const result = await new QueryAgentPocService(configured, execute).run(question)
const result = await new QueryAgentService(configured, execute).run(question)
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[0].temporalBasis).toEqual({ kind: 'constraint', sourceText })
})
@@ -400,7 +406,7 @@ describe('QueryAgent temporal basis policy', () => {
it('never broadens a constraint relative range to all when it returns zero', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'previous_month' }, CONSTRAINT), { success: true, data: '上个月没有。' }])
const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q)
const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q)
expect(execute).toHaveBeenCalledTimes(1)
expect(execute.mock.calls[0][1]).toMatchObject({ timeRange: { kind: 'previous_month' } })
expect(result.traces[0].autoFallback).toBeUndefined()
@@ -410,7 +416,7 @@ describe('QueryAgent temporal basis policy', () => {
const execute = vi.fn(emptyQuery)
const range = { kind: 'absolute', startTime: '2026-08-01T00:00:00+08:00', endTime: '2026-08-31T23:59:59+08:00' }
const configured = provider([queryCall('c1', range, { kind: 'constraint', sourceText: '8 月' }), { success: true, data: '没有。' }])
const result = await new QueryAgentPocService(configured, execute).run('2026 年 8 月有没有给我发过文件')
const result = await new QueryAgentService(configured, execute).run('2026 年 8 月有没有给我发过文件')
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[0].autoFallback).toBeUndefined()
expect(result.traces[0].input.timeRange).toMatchObject({ kind: 'absolute' })
@@ -419,7 +425,7 @@ describe('QueryAgent temporal basis policy', () => {
it('rejects a constraint retry that replaces the user time range', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'previous_month' }, CONSTRAINT), queryCall('c2', { kind: 'all' }, CONSTRAINT), { success: true, data: 'x' }])
const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q)
const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q)
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' })
expect(JSON.stringify(toolMessageAt(configured, 2))).toContain('constraint_time_range_immutable')
@@ -433,7 +439,7 @@ describe('QueryAgent temporal basis policy', () => {
queryCall('c3', { kind: 'all' }, CONSTRAINT),
{ success: true, data: 'x' }
]
const result = await new QueryAgentPocService(provider(responses), execute).run(CONSTRAINT_Q)
const result = await new QueryAgentService(provider(responses), execute).run(CONSTRAINT_Q)
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'invalid_tool_arguments', 'invalid_tool_arguments'])
})
@@ -447,7 +453,7 @@ describe('QueryAgent temporal basis policy', () => {
queryCall('c2', { kind: 'previous_month' }, CONSTRAINT, { messageTypes: ['text'] }),
{ success: true, data: '找到。' }
])
const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q)
const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q)
expect(execute).toHaveBeenCalledTimes(2)
expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'completed'])
})
@@ -455,7 +461,7 @@ describe('QueryAgent temporal basis policy', () => {
it('does not broaden when a recall_hint range already returned messages', async () => {
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 3, messages: [] }))
const configured = provider([queryCall('c1', { kind: 'last_7_days' }, HINT), { success: true, data: 'ok' }])
const result = await new QueryAgentPocService(configured, execute).run(HINT_Q)
const result = await new QueryAgentService(configured, execute).run(HINT_Q)
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[0].autoFallback).toBeUndefined()
expect(vi.mocked(configured.chatWithTools).mock.calls[1]?.[1]).toEqual([])
@@ -466,7 +472,7 @@ describe('QueryAgent temporal basis policy', () => {
.mockResolvedValueOnce({ status: 'completed', returnedCount: 0, messages: [] })
.mockResolvedValueOnce({ status: 'completed', returnedCount: 1, messages: [{ messageRef: 'ref', sourceKind: 'file' }], resolvedTimeRange: { kind: 'all', label: '全部历史' } })
const configured = provider([queryCall('c1', { kind: 'last_7_days' }, HINT), { success: true, data: '找到了。' }])
const result = await new QueryAgentPocService(configured, execute).run(HINT_Q)
const result = await new QueryAgentService(configured, execute).run(HINT_Q)
expect(execute).toHaveBeenCalledTimes(2)
expect(execute.mock.calls[1][1]).toMatchObject({ timeRange: { kind: 'all' } })
// the corrective lookup is Host orchestration, not a model tool call
@@ -477,7 +483,7 @@ describe('QueryAgent temporal basis policy', () => {
it('does not repeat an all-history lookup when a recall_hint query already used all', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'all' }, HINT), queryCall('c2', { kind: 'all' }, HINT), { success: true, data: 'x' }])
const result = await new QueryAgentPocService(configured, execute).run(HINT_Q)
const result = await new QueryAgentService(configured, execute).run(HINT_Q)
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[0].autoFallback).toBeUndefined()
expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' })
@@ -488,7 +494,7 @@ describe('QueryAgent temporal basis policy', () => {
.mockResolvedValueOnce({ status: 'completed', returnedCount: 0, messages: [], query: { resolvedTimeRange: { kind: 'last_7_days', label: '近 7 天' } } })
.mockResolvedValueOnce({ status: 'completed', returnedCount: 1, messages: [{ messageRef: 'ref', text: 'x' }], resolvedTimeRange: { kind: 'all', label: '全部历史' } })
const configured = provider([queryCall('c1', { kind: 'last_7_days' }, HINT), { success: true, data: 'ok' }])
await new QueryAgentPocService(configured, execute).run(HINT_Q)
await new QueryAgentService(configured, execute).run(HINT_Q)
const presented = toolMessageAt(configured, 1)
expect(presented.returnedCount).toBe(0)
expect(presented.fallbackLookup).toMatchObject({ reason: 'soft_temporal_hint_zero_result', timeRange: { kind: 'all' }, returnedCount: 1 })
@@ -498,7 +504,7 @@ describe('QueryAgent temporal basis policy', () => {
it('stops after a corrective lookup that also finds nothing', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'last_7_days' }, HINT), queryCall('c2', { kind: 'this_month' }, HINT), { success: true, data: '都没找到。' }])
const result = await new QueryAgentPocService(configured, execute).run(HINT_Q)
const result = await new QueryAgentService(configured, execute).run(HINT_Q)
// primary + one corrective lookup only; the third attempt is refused because tools are closed
expect(execute).toHaveBeenCalledTimes(2)
expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' })
@@ -507,7 +513,7 @@ describe('QueryAgent temporal basis policy', () => {
it('accepts none with timeRange all', async () => {
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 2, messages: [] }))
const configured = provider([queryCall('c1', { kind: 'all' }, NONE), { success: true, data: 'ok' }])
const result = await new QueryAgentPocService(configured, execute).run('BOBO 给我发过文件吗')
const result = await new QueryAgentService(configured, execute).run('BOBO 给我发过文件吗')
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[0].temporalBasis).toEqual({ kind: 'none' })
})
@@ -515,7 +521,7 @@ describe('QueryAgent temporal basis policy', () => {
it('rejects none combined with a bounded time range', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'last_7_days' }, NONE), { success: true, data: 'x' }])
const result = await new QueryAgentPocService(configured, execute).run('BOBO 给我发过文件吗')
const result = await new QueryAgentService(configured, execute).run('BOBO 给我发过文件吗')
expect(execute).not.toHaveBeenCalled()
expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' })
expect(JSON.stringify(toolMessageAt(configured, 1))).toContain('temporal_basis_mismatch')
@@ -531,7 +537,7 @@ describe('QueryAgent 耗时与请求级诊断记录', () => {
const configured = provider([
{ success: false, error: '模型服务返回了网页而不是 JSON(HTTP 502 Bad Gateway)', errorStatus: 502, errorContentType: 'text/html', htmlInsteadOfJson: true, elapsedMs: 99419 }
])
const result = await new QueryAgentPocService(configured, vi.fn()).run('测试')
const result = await new QueryAgentService(configured, vi.fn()).run('测试')
expect(result.modelCallCount).toBe(1)
expect(result.modelDurationsMs).toHaveLength(1)
expect(result.modelDiagnostics).toEqual([
@@ -545,7 +551,7 @@ describe('QueryAgent 耗时与请求级诊断记录', () => {
it('2 次模型调用 + 1 次工具:耗时数组与调用次数一致', async () => {
const configured = provider([queryCall('c1'), { success: true, data: '完成' }])
const result = await new QueryAgentPocService(configured, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('测试')
const result = await new QueryAgentService(configured, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('测试')
expect(result.modelCallCount).toBe(2)
expect(result.modelDurationsMs).toHaveLength(2)
expect(result.modelDurationsMs.every((value) => typeof value === 'number' && value >= 0)).toBe(true)
@@ -555,7 +561,7 @@ describe('QueryAgent 耗时与请求级诊断记录', () => {
it('1 次模型调用 + 0 次工具:无工具诊断噪声', async () => {
const configured = provider([{ success: true, data: '直接回答' }])
const result = await new QueryAgentPocService(configured, vi.fn()).run('测试')
const result = await new QueryAgentService(configured, vi.fn()).run('测试')
expect(result.modelCallCount).toBe(1)
expect(result.toolCallCount).toBe(0)
expect(result.modelDurationsMs).toHaveLength(1)
@@ -567,10 +573,101 @@ describe('QueryAgent 耗时与请求级诊断记录', () => {
queryCall('c1'),
{ success: false, error: 'AI 请求超时', timedOut: true }
])
const result = await new QueryAgentPocService(configured, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('测试')
const result = await new QueryAgentService(configured, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('测试')
expect(result.modelDurationsMs).toHaveLength(2)
expect(result.modelDiagnostics[1]).toMatchObject({ index: 2, timedOut: true })
expect(result.error).toBe('AI 请求超时')
expect(result.answer).toBeUndefined()
})
})
/**
* 生产查询进度。
*
* 进度必须来自**真实 Runtime 生命周期边界**,不允许定时器伪进度 ——
* 只有一个静态步骤界面加一个计时器时,用户看不到是在等模型、等本地检索,还是等知识库追新。
*/
describe('QueryAgentService progress lifecycle', () => {
it('emits the real understanding -> searching -> organizing -> generating lifecycle', async () => {
const stages: Array<{ stage: string; toolName?: string }> = []
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 1, messages: [{ messageRef: 'r1' }] }))
const service = new QueryAgentService(
provider([
{
success: true,
toolCalls: [
{
id: 'call-1',
name: 'query_messages',
arguments: JSON.stringify({
target: { query: 'BOBO' },
timeRange: { kind: 'all' },
temporalBasis: { kind: 'none' },
limit: 1
})
}
]
},
{ success: true, data: '第一条消息是图片。' }
]),
execute
)
await service.run('我和 BOBO 最开始聊了什么', {
onProgress: (event) => stages.push({ stage: event.stage, toolName: event.toolName })
})
expect(stages.map((item) => item.stage)).toEqual([
'understanding',
'searching',
'organizing_evidence',
'generating_answer',
'completed'
])
// toolName 只在 Host 侧用于诊断片段,UI 文案不会把它暴露给用户。
expect(stages[1].toolName).toBe('query_messages')
})
it('reports understanding again when the model re-plans after a bad call', async () => {
const stages: string[] = []
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 0, messages: [] }))
await new QueryAgentService(
provider([
{ success: true, toolCalls: [{ id: 'c1', name: 'not_a_tool', arguments: '{}' }] },
{ success: true, data: '无法完成。' }
]),
execute
).run('测试', { onProgress: (event) => stages.push(event.stage) })
// 第一个 tool call 被拒绝(没有真正执行工具)→ 仍然算"还没有 Tool 结果",
// 所以第二次模型调用依然是理解阶段,而不是凭空跳到"生成回答"。
expect(stages[0]).toBe('understanding')
expect(stages).toContain('generating_answer')
expect(stages[stages.length - 1]).toBe('completed')
})
it('always finishes the stream even when the provider fails', async () => {
const stages: string[] = []
const result = await new QueryAgentService(
provider([{ success: false, error: 'AI 请求超时' }]),
vi.fn()
).run('测试', { onProgress: (event) => stages.push(event.stage) })
expect(result.errorKind).toBe('provider_failure')
// 不允许把 UI 永久停在某个中间阶段。
expect(stages[0]).toBe('understanding')
expect(stages[stages.length - 1]).toBe('completed')
})
it('never lets a throwing progress listener break the query', async () => {
const result = await new QueryAgentService(
provider([{ success: true, data: '直接回答' }]),
vi.fn()
).run('测试', {
onProgress: () => {
throw new Error('listener 崩了')
}
})
expect(result.answer).toBe('直接回答')
})
})