mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 20:56:21 +08:00
feat: 重构问问微信并完善知识库增量检索
统一问问微信与 Agent Hub 的查询链路 完善查询覆盖度、新鲜度和部分结果表达,避免索引滞后产生错误结论 基于会话实现真正的增量追新与历史补齐 支持后台同步、取消恢复、重启续传以及同步期间继续查询 优化知识库跨会话检索、同步状态、进度展示和侧栏布局
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user