mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 04:20:34 +08:00
feat: 重构问问微信并完善知识库增量检索
统一问问微信与 Agent Hub 的查询链路 完善查询覆盖度、新鲜度和部分结果表达,避免索引滞后产生错误结论 基于会话实现真正的增量追新与历史补齐 支持后台同步、取消恢复、重启续传以及同步期间继续查询 优化知识库跨会话检索、同步状态、进度展示和侧栏布局
This commit is contained in:
@@ -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` 直读聊天数据库,不受索引新鲜度影响。
|
||||
|
||||
@@ -28,12 +28,12 @@ AI 回答后,你可以继续查看它参考了哪些聊天内容、这些内
|
||||
|
||||
来源覆盖受时间范围、会话范围、索引状态和可读媒体影响。例如:
|
||||
|
||||
- Knowledge 正在同步时,新的分析会被暂停;
|
||||
- Knowledge 还没追到最新时,跨会话检索只覆盖到索引当前的时间点,答案会标注这个范围;
|
||||
- 语音没有转写时,AI 可能只能看到消息类型;
|
||||
- 图片无法读取或未启用图片理解时,AI 不应声称知道图片内容;
|
||||
- 你只选择了一个群,答案不会自动代表所有聊天。
|
||||
|
||||
看到“可能遗漏”或“部分覆盖”时,扩大范围、先完成同步或检查原始媒体后再问。
|
||||
Knowledge 在后台同步时**不会**暂停分析:你仍然可以提问,只是答案基于当前已可用的覆盖范围。看到“可能遗漏”或“部分覆盖”时,扩大范围、等同步追上或检查原始媒体后再问。
|
||||
|
||||
## 这不是事实保证
|
||||
|
||||
|
||||
@@ -41,6 +41,19 @@
|
||||
|
||||
你可以点击来源回到档案中的原始消息。产品内部将这些信息称为 Evidence、Citation 和 Search Trace,用户可以把它们理解为“依据、来源标记和查找过程”。详见[如何核对 AI 的回答来源](../concepts/answer-sources.md)。
|
||||
|
||||
## 查找过程和跳回原消息
|
||||
|
||||
查找过程中,界面依次显示真实阶段:**理解问题 → 查找相关聊天 → 整理证据 → 生成回答**。跨会话、大范围检索更慢时,副提示会写明“正在搜索较大范围的聊天记录…”。阶段只在真正进入下一步时前进,不使用定时器或百分比伪造进度。
|
||||
|
||||
查找结束后,界面给出耗时拆解:**总耗时**,以及其中分别花在 **AI 生成** 和 **本地查询** 上的时间。这样你能判断慢在哪——是模型在写答案,还是本机还在翻聊天记录。
|
||||
|
||||
点击来源卡片的 **“跳转到原聊天”** 会真的打开对应会话并定位到那条消息:
|
||||
|
||||
- 群聊来源打开的是那个群,而不是群里某个联系人;
|
||||
- 会加载该消息前后的上下文,并滚动到它、短暂高亮;
|
||||
- 只加载目标消息附近的一段,不会把整个会话历史全部读出来;
|
||||
- 如果这条消息已经不在本地(例如已被删除),界面会明确说明“已打开对应会话,但暂时无法定位原消息”,不会假装跳转成功。
|
||||
|
||||
## 什么时候不要直接相信答案
|
||||
|
||||
- 来源很少,或时间范围与问题不一致;
|
||||
|
||||
@@ -13,7 +13,30 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
|
||||
- **建立本地知识库**:第一次读取当前账号的可检索聊天;
|
||||
- **同步最新记录**:已有索引时,只补充新增或变化的内容。
|
||||
|
||||
同步会在后台运行,完成后页面显示已索引消息、知识片段和磁盘占用。同步期间暂不能开始新的 AI 分析;同步异常时,旧索引仍可能可以继续使用。
|
||||
同步在后台运行,**期间仍然可以正常提问和分析**,不会被禁用。索引还没追完时,答案会基于当前已经可用的部分给出,并在界面标注覆盖范围。
|
||||
|
||||
知识库卡片同时显示两组互相独立的信息:
|
||||
|
||||
- **规模**:已索引消息、知识片段、磁盘占用——说明索引有多大;
|
||||
- **状态与本轮进度**:说明索引现在处于什么状态、这一轮同步在做什么(扫了多少、真正新增了多少、处理到第几个会话)。
|
||||
|
||||
`最新索引` 只表示索引已经覆盖到聊天记录的哪个时间点,**不等于**整库已经建完;进度里的计数是**本轮**的数字,不是全部历史的总数。
|
||||
|
||||
### 状态怎么读
|
||||
|
||||
| 状态 | 含义 |
|
||||
| ---- | ---- |
|
||||
| 可用 · 已追至最新 | 索引已覆盖到聊天记录的最新位置,可以直接用 |
|
||||
| 可用 · 正在追新 | 索引可用,正在后台补充最近新增的消息 |
|
||||
| 可用 · 正在补齐历史 | 索引可用,正在后台补齐较早的历史内容 |
|
||||
| 可用 · 同步已取消 | 索引仍然可用;上一轮同步被取消,已建立的部分保留 |
|
||||
| 可用 · 更新失败 | 索引仍然可用;上一轮同步出错,可以稍后重试 |
|
||||
|
||||
只有确实追平、且没有待补齐内容时才会出现“已追至最新”。索引不可查询时不会显示“可用”。
|
||||
|
||||
### 取消和继续
|
||||
|
||||
同步过程中可以点击 **取消同步**(点击后显示“正在取消…”)。取消只结束当前这一轮,不会删除已经建立的索引,也不会回滚已完成的部分;下次同步会从上次停下的位置继续,不需要从头重扫。中断过的索引仍然可以正常搜索。
|
||||
|
||||
## 账号隔离
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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]',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+314
-21
@@ -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' }
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Vendored
+20
-1
@@ -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 }[],
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([
|
||||
'精确读取',
|
||||
'关键词检索'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
+139
-42
@@ -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('直接回答')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user