test: 暂存代码

This commit is contained in:
电摇小子
2026-08-06 20:29:25 +08:00
parent 307d247660
commit ad4b3a8074
43 changed files with 9978 additions and 512 deletions
+46 -3
View File
@@ -104,6 +104,10 @@ import type { ExportRequest } from '../shared/export'
import { discoverAccounts } from './services/account-discovery'
import { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
import type { VoiceMessageReference } from '../shared/voice-recognition'
import type { AiSearchPipelineRequest } from '../shared/ai-search'
import type { KnowledgeSearchIpcRequest, KnowledgeSearchIpcResult } from '../shared/knowledge'
import { KnowledgeSearchService } from './knowledge/knowledge-search-service'
import { AiSearchPipelineService } from './services/ai-search-pipeline-service'
// electron-vite can close the child's stdout/stderr after spawning Electron.
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
@@ -112,6 +116,8 @@ installSafeConsole()
let voiceService: VoiceService | null = null
let voiceRecognition: VoiceRecognitionUseCase | null = null
let knowledgeSearchService: KnowledgeSearchService | null = null
let aiSearchPipelineService: AiSearchPipelineService | null = null
let imageDecryptService: ImageDecryptService | null = null
let stickerService: StickerService | null = null
let videoAssetService: VideoAssetService | null = null
@@ -430,6 +436,16 @@ app.whenReady().then(async () => {
databasePath: join(app.getPath('userData'), 'cache', 'voice-transcripts.sqlite'),
workerPath: join(__dirname, 'voiceRecognitionWorker.js')
})
knowledgeSearchService = new KnowledgeSearchService(
app.getPath('userData'),
join(__dirname, 'knowledgeWorker.js')
)
aiSearchPipelineService = new AiSearchPipelineService(knowledgeSearchService, aiProviderService)
knowledgeSearchService.onStatusChange((status) => {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('knowledge:status', status)
}
})
voiceRecognition.modelManager.setProgressListener((status) => {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('voice:modelProgress', status)
@@ -501,10 +517,13 @@ app.whenReady().then(async () => {
ipcMain.handle('app-update:install', () => appUpdateService.install())
ipcMain.handle('cache:getSummary', () => getCacheSummary())
ipcMain.handle('cache:clear', async (_, scope: CacheClearScope) => {
const allowedScopes: CacheClearScope[] = ['bootstrap', 'electron', 'all']
const allowedScopes: CacheClearScope[] = ['bootstrap', 'electron', 'knowledge', 'all']
if (!allowedScopes.includes(scope)) return getCacheSummary()
imageDecryptService = null
return clearCache(scope)
return clearCache(scope, {
beforeClearKnowledge: () =>
knowledgeSearchService?.prepareForCacheClear() || Promise.resolve()
})
})
ipcMain.handle('db:init', async (_, key: string, accountRoot?: string) => {
@@ -891,6 +910,29 @@ app.whenReady().then(async () => {
})
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
ipcMain.handle(
'knowledge:search',
(_, request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> => {
if (!knowledgeSearchService) {
throw new Error('本地知识库服务尚未初始化')
}
return knowledgeSearchService.search(request)
}
)
ipcMain.handle('knowledge:getStatus', () => {
if (!knowledgeSearchService) throw new Error('本地知识库服务尚未初始化')
return knowledgeSearchService.getStatus()
})
ipcMain.handle('knowledge:startIndex', () => {
if (!knowledgeSearchService) throw new Error('本地知识库服务尚未初始化')
return knowledgeSearchService.startCurrentAccountIndex()
})
ipcMain.handle('ai-search:run', (event, request: AiSearchPipelineRequest) => {
if (!aiSearchPipelineService) throw new Error('本地搜索服务尚未初始化')
return aiSearchPipelineService.run(request, (progress) => {
if (!event.sender.isDestroyed()) event.sender.send('ai-search:progress', progress)
})
})
ipcMain.handle(
'ai:chat',
@@ -1444,7 +1486,8 @@ app.on('before-quit', (event) => {
const [, nativeCallsDrained] = await Promise.all([
apiServer.stop().catch(() => undefined),
chat.closeChatDbForQuit().catch(() => false),
voiceRecognition?.dispose().catch(() => undefined)
voiceRecognition?.dispose().catch(() => undefined),
knowledgeSearchService?.dispose().catch(() => undefined)
])
if (!nativeCallsDrained) {
console.warn('[Shutdown] WCDB async calls did not fully drain before quit')
+88
View File
@@ -0,0 +1,88 @@
import { createHash } from 'crypto'
import type {
KnowledgeChunk,
KnowledgeChunkerConfig,
KnowledgeNormalizedMessage
} from '../../shared/knowledge'
import { isIndexableKnowledgeMessage } from './normalizer'
function digest(value: string): string {
return createHash('sha256').update(value).digest('hex')
}
function formatChunkText(messages: KnowledgeNormalizedMessage[]): string {
return messages
.map((message) => {
const sender = message.senderName || message.senderId || '未知成员'
return `[${new Date(message.createTime).toISOString()}] ${sender}: ${message.searchableText}`
})
.join('\n')
}
function buildChunk(
messages: KnowledgeNormalizedMessage[],
config: KnowledgeChunkerConfig
): KnowledgeChunk {
const first = messages[0]
const last = messages[messages.length - 1]
const text = formatChunkText(messages)
const messageIds = messages.map((message) => message.messageId)
const participantIds = Array.from(
new Set(messages.map((message) => message.senderId).filter((value): value is string => Boolean(value)))
)
const messageKinds = Array.from(new Set(messages.map((message) => message.kind)))
const identity = `${first.accountId}|${first.conversationId}|${config.version}|${messageIds.join('|')}`
return {
chunkId: digest(identity),
accountId: first.accountId,
conversationId: first.conversationId,
startTime: first.createTime,
endTime: last.createTime,
text,
messageIds,
participantIds,
messageKinds,
contentHash: digest(`${identity}|${text}`),
chunkerVersion: config.version
}
}
/** Chunks one conversation only; cross-conversation chunks are never allowed. */
export function chunkConversation(
messages: KnowledgeNormalizedMessage[],
config: KnowledgeChunkerConfig
): KnowledgeChunk[] {
const sorted = messages
.filter(isIndexableKnowledgeMessage)
.slice()
.sort((left, right) => left.createTime - right.createTime || left.messageId.localeCompare(right.messageId))
if (!sorted.length) return []
const conversationId = sorted[0].conversationId
const accountId = sorted[0].accountId
if (sorted.some((message) => message.conversationId !== conversationId || message.accountId !== accountId)) {
throw new Error('Conversation chunker received messages from multiple accounts or conversations')
}
const chunks: KnowledgeChunk[] = []
let current: KnowledgeNormalizedMessage[] = []
let currentCharacters = 0
for (const message of sorted) {
const previous = current[current.length - 1]
const nextCharacters = currentCharacters + message.searchableText.length
const shouldSplit =
current.length > 0 &&
(message.createTime - previous.createTime > config.maxGapMs ||
current.length >= config.maxMessages ||
nextCharacters > config.maxCharacters)
if (shouldSplit) {
chunks.push(buildChunk(current, config))
current = []
currentCharacters = 0
}
current.push(message)
currentCharacters += message.searchableText.length
}
if (current.length) chunks.push(buildChunk(current, config))
return chunks
}
@@ -0,0 +1,611 @@
import * as chat from '../services/chat-service'
import type {
KnowledgeAttachmentMetadata,
KnowledgeEvidence,
KnowledgeMessageKind,
KnowledgeRuntimeStatus,
KnowledgeSearchRequest,
KnowledgeSearchIpcRequest,
KnowledgeSearchIpcResult,
KnowledgeSearchResult,
KnowledgeSourceMessage
} from '../../shared/knowledge'
import {
DEFAULT_KNOWLEDGE_CHUNKER,
DEFAULT_KNOWLEDGE_FTS_CONFIG,
emptyKnowledgeSearchTimings
} from '../../shared/knowledge'
import { KnowledgeService } from './knowledge-service'
const FALLBACK_LIMIT = 240
const MAX_SENDER_NAME_CONVERSATIONS = 8
const MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH = 700
function looksLikeOpaqueSenderId(value: string | undefined): boolean {
const normalized = value?.trim() || ''
return (
normalized.startsWith('wxid_') ||
normalized.endsWith('@chatroom') ||
/^\d{6,}$/.test(normalized)
)
}
function groupMemberDisplayName(member: chat.GroupSnapshot['members'][number]): string {
return (
[member.groupNickname, member.wechatNickname, member.nickname, member.remark]
.map((value) => value.trim())
.find((value) => value && !looksLikeOpaqueSenderId(value)) || ''
)
}
function sourceMessageId(message: chat.FormattedMessage): string {
if (message.localId) return `local:${message.localId}`
if (message.id) return String(message.id)
return `${message.createTime || 0}:${message.serverId || message.content}`
}
function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind {
if (message.voiceTranscript || message.type === '语音') return 'voice'
if (message.contentData?.type === 'share' || message.contentData?.type === 'miniProgram') {
return message.contentData.type === 'share' && message.contentData.typeVal === '6'
? 'file'
: 'link'
}
if (message.contentData?.type === 'system') return 'system'
return message.content?.trim() ? 'text' : 'other'
}
function sourceTextAndAttachment(message: chat.FormattedMessage): {
text?: string
attachment?: KnowledgeAttachmentMetadata
} {
const text = message.content?.trim() || ''
const content = message.contentData
if (!content) {
return {
text: text || undefined,
attachment: message.exportMediaName
? {
name: message.exportMediaName,
kind: message.exportMediaType === 'file' ? 'file' : 'other'
}
: undefined
}
}
if (content.type === 'share') {
const title = content.title?.trim() || ''
const description = content.des?.trim() || ''
return {
text: [text, title, description].filter(Boolean).join('\n') || undefined,
attachment:
title || content.url
? {
name: title || content.url,
kind: content.typeVal === '6' ? 'file' : 'link',
url: content.url
}
: undefined
}
}
if (content.type === 'miniProgram') {
return {
text: [text, content.title, content.description].filter(Boolean).join('\n') || undefined,
attachment: content.title ? { name: content.title, kind: 'link' } : undefined
}
}
if (content.type === 'quote') {
return {
text:
[text, content.title, content.content, content.quotedContent].filter(Boolean).join('\n') ||
undefined
}
}
if (content.type === 'forwardBundle') {
return {
text: [text, content.title, content.description, ...content.items.map((item) => item.text)]
.filter(Boolean)
.join('\n')
}
}
return { text: text || undefined }
}
function toSourceMessage(
accountId: string,
conversationId: string,
message: chat.FormattedMessage
): KnowledgeSourceMessage | null {
if (!message.createTime) return null
const extracted = sourceTextAndAttachment(message)
const voiceTranscript = message.voiceTranscript?.trim() || undefined
if (!extracted.text && !extracted.attachment && !voiceTranscript) return null
return {
accountId,
conversationId,
messageId: sourceMessageId(message),
// Existing chat messages use Unix seconds; the knowledge contract uses milliseconds.
createTime: message.createTime * 1000,
senderId: message.senderId || message.from || undefined,
senderName: message.isSender ? '我' : message.name || undefined,
kind: sourceKind(message),
text: extracted.text,
attachment: extracted.attachment,
voiceTranscript
}
}
function normalizeComparable(value: string): string {
return value.toLocaleLowerCase().replace(/\s+/g, '')
}
function fallbackTermScore(message: chat.FormattedMessage, terms: string[]): number {
const source = toSourceMessage('fallback', 'fallback', message)
const text = `${source?.text || ''}\n${source?.voiceTranscript || ''}\n${source?.attachment?.name || ''}`
const normalized = normalizeComparable(text)
return terms.reduce((score, term) => {
const normalizedTerm = normalizeComparable(term)
return normalizedTerm && normalized.includes(normalizedTerm)
? score + normalizedTerm.length
: score
}, 0)
}
/**
* Main-process adapter for the read-only chat archive. It never passes source
* database handles or keys to the worker; only normalized serializable values.
*/
export class KnowledgeSearchService {
private readonly service: KnowledgeService
private readonly indexing = new Map<string, Promise<void>>()
private readonly statusByAccount = new Map<string, KnowledgeRuntimeStatus>()
private readonly statusListeners = new Set<(status: KnowledgeRuntimeStatus) => void>()
private wcdbReadTail: Promise<void> = Promise.resolve()
constructor(userDataPath: string, workerPath: string) {
this.service = new KnowledgeService(userDataPath, workerPath)
}
startCurrentAccountIndex(): KnowledgeRuntimeStatus {
const accountId = this.currentAccountId()
if (!accountId) return this.emptyStatus('')
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
if (this.indexing.has(accountId)) return current
const started: KnowledgeRuntimeStatus = {
...current,
state: current.indexedMessageCount ? 'syncing' : 'building',
processedMessages: 0,
totalMessages: current.sourceMessageCount,
estimatedRemainingMs: null,
lastError: undefined
}
this.publishStatus(started)
const task = this.indexAccount(accountId)
.catch((error) => {
const previous = this.statusByAccount.get(accountId)
this.publishStatus({
...(previous || this.emptyStatus(accountId)),
state: 'error',
lastError: error instanceof Error ? error.message : String(error)
})
throw error
})
.finally(() => {
this.indexing.delete(accountId)
void this.refreshStatus(accountId).catch(() => undefined)
})
this.indexing.set(accountId, task)
void task.catch((error) => {
console.warn('[Knowledge] background index failed:', error)
})
return started
}
async search(request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> {
const accountId = this.currentAccountId()
if (!accountId) return this.searchFallback(request, 'unavailable')
try {
const searchRequest: Omit<KnowledgeSearchRequest, 'databaseRoot'> = {
accountId,
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
text: request.text,
terms: request.terms,
limit: Math.max(1, Math.min(request.limit || FALLBACK_LIMIT, FALLBACK_LIMIT)),
conversationIds: request.conversationIds,
senderIds: request.senderIds,
startTime: request.startTime === undefined ? undefined : request.startTime * 1000,
endTime: request.endTime === undefined ? undefined : request.endTime * 1000
}
const result = await this.searchKnowledge(searchRequest)
// 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)
}
if (this.indexing.has(accountId)) {
return {
...result,
source: 'knowledge',
totalMessages: result.indexedMessageCount
}
}
return this.searchFallback(request, 'unavailable')
} catch (error) {
console.warn('[Knowledge] search failed, using legacy fallback:', error)
return this.searchFallback(request, 'error')
}
}
async dispose(): Promise<void> {
await this.service.dispose()
}
/** Safely release derived SQLite handles before the cache screen removes them. */
async prepareForCacheClear(): Promise<void> {
if (this.indexing.size) {
throw new Error('本地知识库正在同步,请等待同步完成后再清理')
}
await this.service.dispose()
const accountIds = Array.from(this.statusByAccount.keys())
this.statusByAccount.clear()
accountIds.forEach((accountId) => this.publishStatus(this.emptyStatus(accountId)))
}
onStatusChange(listener: (status: KnowledgeRuntimeStatus) => void): () => void {
this.statusListeners.add(listener)
return () => this.statusListeners.delete(listener)
}
async getStatus(): Promise<KnowledgeRuntimeStatus> {
const accountId = this.currentAccountId()
if (!accountId) return this.emptyStatus('')
return this.refreshStatus(accountId)
}
private currentAccountId(): string {
if (!chat.isReady()) return ''
return chat.getSelfAccountInfo()?.wxid || chat.getCurrentAccountRoot()
}
private async indexAccount(accountId: string): Promise<void> {
const contacts = await this.listContacts()
let processedMessages = 0
const startedAt = Date.now()
this.publishStatus({
...(this.statusByAccount.get(accountId) || this.emptyStatus(accountId)),
state: this.statusByAccount.get(accountId)?.indexedMessageCount ? 'syncing' : 'building',
processedMessages: 0,
totalMessages: null,
estimatedRemainingMs: null
})
for (const [index, contact] of contacts.entries()) {
// 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)
const sourceMessages = messages
.map((message) => toSourceMessage(accountId, contact.md5, message))
.filter((message): message is KnowledgeSourceMessage => Boolean(message))
await this.service.index(
{
accountId,
conversations: [
{
conversationId: contact.md5,
completeSnapshot: true,
messages: sourceMessages
}
],
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
sourceMessageCount:
index === contacts.length - 1 ? processedMessages + sourceMessages.length : undefined
},
(progress) => {
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
this.publishStatus({
...current,
state: current.indexedMessageCount ? 'syncing' : 'building',
processedMessages: processedMessages + progress.processedMessages,
totalMessages: null,
currentConversationId: progress.conversationId,
estimatedRemainingMs: null
})
}
)
processedMessages += sourceMessages.length
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
this.publishStatus({
...current,
state: current.indexedMessageCount ? 'syncing' : 'building',
processedMessages,
totalMessages: null,
currentConversationId: contact.md5,
estimatedRemainingMs: null
})
}
await this.refreshStatus(accountId, {
processedMessages,
totalMessages: processedMessages,
startedAt
})
}
private async searchFallback(
request: KnowledgeSearchIpcRequest,
fallbackReason: 'unavailable' | 'indexing' | 'error'
): Promise<KnowledgeSearchIpcResult> {
const startedAt = Date.now()
const contacts = await this.listContacts()
const allowedConversations = new Set(request.conversationIds || [])
const sourceContacts = allowedConversations.size
? contacts.filter((contact) => allowedConversations.has(contact.md5))
: contacts
const senderIds = new Set(request.senderIds || [])
const terms = request.terms.filter((term) => term.trim().length >= 2)
const matches: Array<{
contact: (typeof sourceContacts)[number]
message: chat.FormattedMessage
score: number
}> = []
let totalMessages = 0
for (const contact of sourceContacts) {
const messages = await this.listMessages(contact.md5, request.startTime, request.endTime)
totalMessages += messages.length
for (const message of messages) {
matches.push({
contact,
message,
score: fallbackTermScore(message, terms)
})
}
}
const filtered = matches
.filter(({ message, score }) => {
const senderMatches = !senderIds.size || senderIds.has(message.senderId || message.from)
const termMatches = !terms.length || score > 0
return senderMatches && termMatches
})
.sort(
(left, right) =>
right.score - left.score ||
(right.message.createTime || 0) - (left.message.createTime || 0)
)
.slice(0, Math.max(1, Math.min(request.limit || FALLBACK_LIMIT, FALLBACK_LIMIT)))
const result: KnowledgeSearchIpcResult = {
source: 'fallback',
fallbackReason,
state: fallbackReason === 'indexing' ? 'indexing' : 'unavailable',
indexedMessageCount: 0,
indexedChunkCount: 0,
totalMessages,
timings: {
...emptyKnowledgeSearchTimings(),
messageLoadMs: Date.now() - startedAt,
totalMs: Date.now() - startedAt
},
evidence: filtered.map(({ contact, message, score }) => ({
chunkId: `fallback:${contact.md5}:${sourceMessageId(message)}`,
conversationId: contact.md5,
startTime: (message.createTime || 0) * 1000,
endTime: (message.createTime || 0) * 1000,
messageId: sourceMessageId(message),
senderId: message.senderId || message.from || undefined,
sender: message.isSender ? '我' : message.name || '未知成员',
timestamp: (message.createTime || 0) * 1000,
messageIds: [sourceMessageId(message)],
text: sourceTextAndAttachment(message).text || message.content || `[${message.type}]`,
score: -score
}))
}
return {
...result,
evidence: await this.enrichEvidenceSenders(result.evidence)
}
}
/**
* SQLite has a finite bind-parameter limit. Group/one-to-one scope filters
* can contain over one thousand conversations, so split only the Worker
* query and merge real Evidence instead of dropping the selected scope.
*/
private async searchKnowledge(
request: Omit<KnowledgeSearchRequest, 'databaseRoot'>
): Promise<KnowledgeSearchResult> {
const conversationIds = Array.from(new Set(request.conversationIds || []))
if (conversationIds.length <= MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH) {
return this.searchWorker(request)
}
const partialResults: KnowledgeSearchResult[] = []
for (
let start = 0;
start < conversationIds.length;
start += MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH
) {
partialResults.push(
await this.searchWorker({
...request,
conversationIds: conversationIds.slice(
start,
start + MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH
)
})
)
}
const evidenceByIdentity = new Map<string, KnowledgeEvidence>()
partialResults
.flatMap((result) => result.evidence)
.forEach((item) => {
const identity = `${item.conversationId}:${item.messageId}`
const existing = evidenceByIdentity.get(identity)
if (!existing || (item.score || 0) < (existing.score || 0)) {
evidenceByIdentity.set(identity, item)
}
})
const mergeStartedAt = Date.now()
const mergedEvidence = Array.from(evidenceByIdentity.values())
.sort(
(left, right) => (left.score || 0) - (right.score || 0) || right.timestamp - left.timestamp
)
.slice(0, request.limit)
const timings = partialResults.reduce(
(total, result) => ({
workerIpcMs: total.workerIpcMs + (result.timings?.workerIpcMs || 0),
workerBootMs: total.workerBootMs + (result.timings?.workerBootMs || 0),
dispatchMs: total.dispatchMs + (result.timings?.dispatchMs || 0),
workerSqlMs: total.workerSqlMs + (result.timings?.workerSqlMs || 0),
responseTransferMs: total.responseTransferMs + (result.timings?.responseTransferMs || 0),
responseSerializeMs: total.responseSerializeMs + (result.timings?.responseSerializeMs || 0),
ftsMs: total.ftsMs + (result.timings?.ftsMs || 0),
messageLoadMs: total.messageLoadMs + (result.timings?.messageLoadMs || 0),
chunkExpandMs: total.chunkExpandMs + (result.timings?.chunkExpandMs || 0),
rankingMs: total.rankingMs + (result.timings?.rankingMs || 0),
totalMs: total.totalMs + (result.timings?.totalMs || 0)
}),
emptyKnowledgeSearchTimings()
)
const mergeRankingMs = Date.now() - mergeStartedAt
timings.rankingMs += mergeRankingMs
timings.totalMs += mergeRankingMs
return {
state: partialResults.some((result) => result.state === 'ready')
? 'ready'
: partialResults.some((result) => result.state === 'indexing')
? 'indexing'
: 'unavailable',
indexedMessageCount: Math.max(...partialResults.map((result) => result.indexedMessageCount)),
indexedChunkCount: Math.max(...partialResults.map((result) => result.indexedChunkCount)),
evidence: mergedEvidence,
timings
}
}
private async searchWorker(
request: Omit<KnowledgeSearchRequest, 'databaseRoot'>
): Promise<KnowledgeSearchResult> {
const startedAt = Date.now()
const result = await this.service.search(request)
const timings = result.timings || emptyKnowledgeSearchTimings()
return {
...result,
timings: {
...timings,
workerIpcMs: timings.workerIpcMs || Math.max(0, Date.now() - startedAt - timings.totalMs),
workerSqlMs: timings.workerSqlMs || timings.totalMs
}
}
}
private listContacts(): ReturnType<typeof chat.listContactsAsync> {
return this.enqueueWcdbRead(() => chat.listContactsAsync())
}
private listMessages(
conversationId: string,
startTime?: number,
endTime?: number
): ReturnType<typeof chat.listMessagesAsync> {
return this.enqueueWcdbRead(() => chat.listMessagesAsync(conversationId, startTime, endTime))
}
private async toKnowledgeResult(
result: KnowledgeSearchResult
): Promise<KnowledgeSearchIpcResult> {
return {
...result,
evidence: await this.enrichEvidenceSenders(result.evidence),
source: 'knowledge',
totalMessages: result.indexedMessageCount
}
}
private async enrichEvidenceSenders(evidence: KnowledgeEvidence[]): 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
const contacts = await this.listContacts()
const groupConversationIds = new Set(
contacts.filter((contact) => contact.type === 'group').map((contact) => contact.md5)
)
const memberNamesByConversation = new Map<string, Map<string, string>>()
for (const conversationId of candidateConversationIds) {
if (!groupConversationIds.has(conversationId)) continue
const snapshot = await this.enqueueWcdbRead(() => chat.getGroupSnapshotAsync(conversationId))
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)
}
return evidence.map((item) => {
const sender = memberNamesByConversation.get(item.conversationId)?.get(item.senderId || '')
return sender ? { ...item, sender } : item
})
}
private enqueueWcdbRead<T>(operation: () => Promise<T>): Promise<T> {
const result = this.wcdbReadTail.then(operation, operation)
// Keep the queue usable after a read failure while returning that failure to its caller.
this.wcdbReadTail = result.then(
() => undefined,
() => undefined
)
return result
}
private emptyStatus(accountId: string): KnowledgeRuntimeStatus {
return {
accountId,
state: 'unavailable',
indexedMessageCount: 0,
indexedChunkCount: 0,
sourceMessageCount: null,
processedMessages: 0,
totalMessages: null,
estimatedRemainingMs: null,
databaseBytes: 0,
walBytes: 0,
shmBytes: 0
}
}
private async refreshStatus(
accountId: string,
progress?: Pick<KnowledgeRuntimeStatus, 'processedMessages' | 'totalMessages'> & {
startedAt?: number
}
): Promise<KnowledgeRuntimeStatus> {
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 processedMessages =
progress?.processedMessages ?? current?.processedMessages ?? remote.processedMessages
const totalMessages = progress?.totalMessages ?? remote.sourceMessageCount
const state = indexing
? remote.indexedMessageCount > 0
? 'syncing'
: 'building'
: remote.state
const status: KnowledgeRuntimeStatus = {
...remote,
state,
processedMessages,
totalMessages,
estimatedRemainingMs: null
}
this.publishStatus(status)
return status
}
private publishStatus(status: KnowledgeRuntimeStatus): void {
this.statusByAccount.set(status.accountId, status)
for (const listener of this.statusListeners) listener(status)
}
}
+54
View File
@@ -0,0 +1,54 @@
import { join } from 'path'
import type {
KnowledgeCapacityPreflight,
KnowledgeCapacityPreflightRequest,
KnowledgeIndexProgress,
KnowledgeIndexRequest,
KnowledgeIndexResult,
KnowledgeRuntimeStatus,
KnowledgeSearchRequest,
KnowledgeSearchResult,
KnowledgeStatusRequest
} from '../../shared/knowledge'
import { KnowledgeWorkerHost } from './knowledge-worker-host'
/** Minimal main-process service; no renderer API is exposed in Task 0Task 2. */
export class KnowledgeService {
private readonly worker: KnowledgeWorkerHost
constructor(userDataPath: string, workerPath: string) {
this.worker = new KnowledgeWorkerHost(workerPath)
this.databaseRoot = join(userDataPath, 'knowledge')
}
private readonly databaseRoot: string
index(
request: Omit<KnowledgeIndexRequest, 'databaseRoot'>,
onProgress?: (progress: KnowledgeIndexProgress) => void
): Promise<KnowledgeIndexResult> {
return this.worker.index({ ...request, databaseRoot: this.databaseRoot }, onProgress)
}
preflight(
request: Omit<KnowledgeCapacityPreflightRequest, 'databaseRoot'>
): Promise<KnowledgeCapacityPreflight> {
return this.worker.preflight({ ...request, databaseRoot: this.databaseRoot })
}
remove(accountId: string): Promise<{ removed: true }> {
return this.worker.remove(accountId, this.databaseRoot)
}
search(request: Omit<KnowledgeSearchRequest, 'databaseRoot'>): Promise<KnowledgeSearchResult> {
return this.worker.search({ ...request, databaseRoot: this.databaseRoot })
}
status(request: Omit<KnowledgeStatusRequest, 'databaseRoot'>): Promise<KnowledgeRuntimeStatus> {
return this.worker.status({ ...request, databaseRoot: this.databaseRoot })
}
dispose(): Promise<void> {
return this.worker.dispose()
}
}
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
import { fork, type ChildProcess } from 'child_process'
import { randomUUID } from 'crypto'
import type {
KnowledgeCapacityPreflight,
KnowledgeCapacityPreflightRequest,
KnowledgeIndexProgress,
KnowledgeIndexRequest,
KnowledgeIndexResult,
KnowledgeRuntimeStatus,
KnowledgeSearchRequest,
KnowledgeSearchResult,
KnowledgeStatusRequest,
KnowledgeWorkerRequest,
KnowledgeWorkerResponse
} from '../../shared/knowledge'
type WorkerResult =
| KnowledgeIndexResult
| KnowledgeCapacityPreflight
| KnowledgeSearchResult
| KnowledgeRuntimeStatus
| { removed: true }
type PendingRequest = {
resolve: (result: WorkerResult) => void
reject: (error: Error) => void
onProgress?: (progress: KnowledgeIndexProgress) => void
sentAt: number
workerBootStartedAt?: number
}
/**
* Main-process boundary for the derived knowledge database. The child runs
* with ELECTRON_RUN_AS_NODE so synchronous node:sqlite calls never block UI.
*/
export class KnowledgeWorkerHost {
private child: ChildProcess | null = null
private childStartedAt = 0
private readonly pending = new Map<string, PendingRequest>()
constructor(private readonly workerPath: string) {}
index(
payload: KnowledgeIndexRequest,
onProgress?: (progress: KnowledgeIndexProgress) => void
): Promise<KnowledgeIndexResult> {
return this.request('index', payload, onProgress) as Promise<KnowledgeIndexResult>
}
preflight(payload: KnowledgeCapacityPreflightRequest): Promise<KnowledgeCapacityPreflight> {
return this.request('preflight', payload) as Promise<KnowledgeCapacityPreflight>
}
search(payload: KnowledgeSearchRequest): Promise<KnowledgeSearchResult> {
return this.request('search', payload) as Promise<KnowledgeSearchResult>
}
status(payload: KnowledgeStatusRequest): Promise<KnowledgeRuntimeStatus> {
return this.request('status', payload) as Promise<KnowledgeRuntimeStatus>
}
remove(accountId: string, databaseRoot: string): Promise<{ removed: true }> {
return this.request('remove', { accountId, databaseRoot }) as Promise<{ removed: true }>
}
cancel(targetRequestId: string): Promise<{ removed: true }> {
return this.request('cancel', { targetRequestId }) as Promise<{ removed: true }>
}
async dispose(): Promise<void> {
const child = this.child
if (!child) return
try {
await this.request('close', {})
} catch {
// The child is about to be stopped; its only job is a derived local index.
}
if (this.child === child) this.child = null
if (!child.killed) child.kill()
}
private request(
type: KnowledgeWorkerRequest['type'],
payload: KnowledgeWorkerRequest['payload'],
onProgress?: (progress: KnowledgeIndexProgress) => 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 }
return new Promise((resolve, reject) => {
this.pending.set(requestId, {
resolve,
reject,
onProgress,
sentAt,
workerBootStartedAt: hadWorker ? undefined : this.childStartedAt
})
child.send(request, (error) => {
if (error) this.finish(requestId, undefined, error)
})
})
}
private ensureChild(): ChildProcess {
if (this.child?.connected) return this.child
const child = fork(this.workerPath, [], {
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
serialization: 'advanced',
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
})
child.on('message', (message: KnowledgeWorkerResponse) => {
if (message?.version !== 1) return
if (message.type === 'progress') {
const pending = this.pending.get(message.requestId)
if (pending && message.payload)
pending.onProgress?.(message.payload as KnowledgeIndexProgress)
return
}
this.finish(
message.requestId,
message.payload as WorkerResult | undefined,
message.type === 'error'
? new Error(message.error || 'Knowledge worker failed')
: undefined,
message.transport
)
})
child.once('error', (error) => this.failAll(error))
child.once('exit', (code) => {
if (this.child === child) this.child = null
this.failAll(new Error(`Knowledge worker exited (${code ?? 'unknown'})`))
})
this.child = child
this.childStartedAt = Date.now()
return child
}
private finish(
requestId: string,
result?: WorkerResult,
error?: Error,
transport?: KnowledgeWorkerResponse['transport']
): void {
const pending = this.pending.get(requestId)
if (!pending) return
this.pending.delete(requestId)
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'))
}
private applyTransportTimings(
result: WorkerResult,
pending: PendingRequest,
transport?: KnowledgeWorkerResponse['transport']
): WorkerResult {
if (!('timings' in result) || !transport) return result
const receivedAt = Date.now()
const workerBootMs = pending.workerBootStartedAt
? Math.max(0, transport.workerReceivedAt - pending.workerBootStartedAt)
: 0
const dispatchMs = Math.max(0, transport.workerReceivedAt - pending.sentAt)
const responseTransferMs = Math.max(0, receivedAt - transport.workerCompletedAt)
return {
...result,
timings: {
...result.timings,
workerBootMs,
dispatchMs,
workerSqlMs: result.timings.totalMs,
responseSerializeMs: transport.responseSerializeMs,
responseTransferMs,
workerIpcMs: workerBootMs + dispatchMs + responseTransferMs
}
}
}
private failAll(error: Error): void {
for (const requestId of this.pending.keys()) this.finish(requestId, undefined, error)
}
}
+200
View File
@@ -0,0 +1,200 @@
import type {
KnowledgeCapacityPreflightRequest,
KnowledgeIndexRequest,
KnowledgeRuntimeStatus,
KnowledgeSearchRequest,
KnowledgeStatusRequest,
KnowledgeWorkerRequest,
KnowledgeWorkerResponse
} from '../../shared/knowledge'
import { emptyKnowledgeSearchTimings } from '../../shared/knowledge'
import {
KnowledgeStore,
estimateKnowledgeCapacityPreflight,
getKnowledgeDatabasePath,
removeKnowledgeDatabase
} from './knowledge-store'
import { existsSync } from 'fs'
import { serialize } from 'v8'
const stores = new Map<string, KnowledgeStore>()
const controllers = new Map<string, AbortController>()
function send(
message: KnowledgeWorkerResponse,
transport?: KnowledgeWorkerResponse['transport']
): void {
if (process.send) process.send({ ...message, transport })
}
function sendSearchResult(
request: KnowledgeWorkerRequest,
payload: KnowledgeWorkerResponse['payload'],
workerReceivedAt: number
): void {
const serializeStartedAt = Date.now()
// This measures the actual payload encoding workload before Node IPC performs
// its own transfer. It lets diagnostics separate payload cost from SQL time.
serialize(payload)
const responseSerializeMs = Date.now() - serializeStartedAt
send(
{ version: 1, type: 'result', requestId: request.requestId, payload },
{ workerReceivedAt, workerCompletedAt: Date.now(), responseSerializeMs }
)
}
function storeKey(databaseRoot: string, accountId: string): string {
return getKnowledgeDatabasePath(databaseRoot, accountId)
}
function getStore(
request: Pick<KnowledgeIndexRequest, 'databaseRoot' | 'accountId' | 'fts'>
): KnowledgeStore {
const key = storeKey(request.databaseRoot, request.accountId)
let store = stores.get(key)
if (!store) {
store = new KnowledgeStore(request.databaseRoot, request.accountId, request.fts)
stores.set(key, store)
}
return store
}
function closeStore(databaseRoot: string, accountId: string): void {
const key = storeKey(databaseRoot, accountId)
const store = stores.get(key)
if (store) store.close()
stores.delete(key)
}
async function handleIndex(
request: KnowledgeWorkerRequest,
payload: KnowledgeIndexRequest
): Promise<void> {
const controller = new AbortController()
controllers.set(request.requestId, controller)
try {
const result = await getStore(payload).index(payload, controller.signal, (progress) => {
send({ version: 1, type: 'progress', requestId: request.requestId, payload: progress })
})
send({ version: 1, type: 'result', requestId: request.requestId, payload: result })
} finally {
controllers.delete(request.requestId)
}
}
async function handlePreflight(
request: KnowledgeWorkerRequest,
payload: KnowledgeCapacityPreflightRequest
): Promise<void> {
const result = await estimateKnowledgeCapacityPreflight(payload)
send({ version: 1, type: 'result', requestId: request.requestId, payload: result })
}
async function handleSearch(
request: KnowledgeWorkerRequest,
payload: KnowledgeSearchRequest
): Promise<void> {
const workerReceivedAt = Date.now()
const path = getKnowledgeDatabasePath(payload.databaseRoot, payload.accountId)
if (!existsSync(path)) {
sendSearchResult(
request,
{
state: 'unavailable',
evidence: [],
indexedMessageCount: 0,
indexedChunkCount: 0,
timings: emptyKnowledgeSearchTimings()
},
workerReceivedAt
)
return
}
const result = getStore(payload).searchWithStatus(payload)
sendSearchResult(request, result, workerReceivedAt)
}
async function handleStatus(
request: KnowledgeWorkerRequest,
payload: KnowledgeStatusRequest
): Promise<void> {
const path = getKnowledgeDatabasePath(payload.databaseRoot, payload.accountId)
if (!existsSync(path)) {
const unavailable: KnowledgeRuntimeStatus = {
accountId: payload.accountId,
state: 'unavailable',
indexedMessageCount: 0,
indexedChunkCount: 0,
sourceMessageCount: null,
processedMessages: 0,
totalMessages: null,
estimatedRemainingMs: null,
databaseBytes: 0,
walBytes: 0,
shmBytes: 0
}
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
return
}
send({
version: 1,
type: 'result',
requestId: request.requestId,
payload: getStore(payload).getRuntimeStatus()
})
}
async function handle(request: KnowledgeWorkerRequest): Promise<void> {
try {
if (request.type === 'cancel') {
const payload = request.payload as { targetRequestId: string }
controllers.get(payload.targetRequestId)?.abort()
send({ version: 1, type: 'result', requestId: request.requestId, payload: { removed: true } })
return
}
if (request.type === 'close') {
for (const controller of controllers.values()) controller.abort()
for (const store of stores.values()) store.close()
stores.clear()
send({ version: 1, type: 'result', requestId: request.requestId, payload: { removed: true } })
process.disconnect?.()
return
}
if (request.type === 'remove') {
const payload = request.payload as { accountId: string; databaseRoot: string }
closeStore(payload.databaseRoot, payload.accountId)
removeKnowledgeDatabase(payload.databaseRoot, payload.accountId)
send({ version: 1, type: 'result', requestId: request.requestId, payload: { removed: true } })
return
}
if (request.type === 'preflight') {
await handlePreflight(request, request.payload as KnowledgeCapacityPreflightRequest)
return
}
if (request.type === 'search') {
await handleSearch(request, request.payload as KnowledgeSearchRequest)
return
}
if (request.type === 'status') {
await handleStatus(request, request.payload as KnowledgeStatusRequest)
return
}
if (request.type === 'index') {
await handleIndex(request, request.payload as KnowledgeIndexRequest)
return
}
throw new Error(`Unsupported knowledge worker request: ${String(request.type)}`)
} catch (error) {
send({
version: 1,
type: 'error',
requestId: request.requestId,
error: error instanceof Error ? error.message : String(error)
})
}
}
process.on('message', (message: KnowledgeWorkerRequest) => {
if (message?.version !== 1) return
void handle(message)
})
+55
View File
@@ -0,0 +1,55 @@
import { createHash } from 'crypto'
import type {
KnowledgeNormalizedMessage,
KnowledgeSourceMessage
} from '../../shared/knowledge'
const compact = (value: string | undefined): string => value?.replace(/\s+/g, ' ').trim() || ''
function digest(value: string): string {
return createHash('sha256').update(value).digest('hex')
}
/**
* Converts a read-only archive record into text safe for local search. Paths,
* binary media and raw voice data are deliberately excluded.
*/
export function normalizeKnowledgeMessage(
source: KnowledgeSourceMessage
): KnowledgeNormalizedMessage {
const sections: string[] = []
const messageText = compact(source.text)
if (messageText) sections.push(messageText)
const transcript = compact(source.voiceTranscript)
if (transcript) sections.push(`语音转写:${transcript}`)
const attachmentName = compact(source.attachment?.name)
if (attachmentName) {
const label = source.attachment?.kind === 'link' ? '链接' : '附件'
sections.push(`${label}${attachmentName}`)
}
const url = compact(source.attachment?.url)
if (url) sections.push(`地址:${url}`)
const searchableText = sections.join('\n')
return {
...source,
text: messageText || undefined,
voiceTranscript: transcript || undefined,
searchableText,
contentHash: digest(
JSON.stringify({
messageId: source.messageId,
createTime: source.createTime,
senderId: source.senderId || '',
kind: source.kind,
searchableText
})
)
}
}
export function isIndexableKnowledgeMessage(message: KnowledgeNormalizedMessage): boolean {
return Boolean(message.searchableText.trim())
}
+6
View File
@@ -0,0 +1,6 @@
import type { KnowledgeWorkerRequest, KnowledgeWorkerResponse } from '../../shared/knowledge'
export const KNOWLEDGE_WORKER_PROTOCOL_VERSION = 1 as const
export type WorkerKnowledgeRequest = KnowledgeWorkerRequest
export type WorkerKnowledgeResponse = KnowledgeWorkerResponse
+185
View File
@@ -0,0 +1,185 @@
import type { AiSearchAgentToolName, AiSearchAgentTraceItem } from '../../shared/ai-search'
export const MAX_AGENT_TOOL_CALLS = 5
export type AgentAction =
| { action: 'tool'; tool: AiSearchAgentToolName; arguments: Record<string, unknown> }
| { action: 'finalize'; reason: string }
export interface AgentToolResult {
summary: Record<string, unknown>
candidateCount: number
/** A host-owned coverage signal, never supplied by the model. */
finalizeReason?: string
}
export interface ControlledSearchAgentOptions {
question: string
scopeLabel: string
rangeLabel: string
maxToolCalls?: number
decide: (prompt: string) => Promise<string | undefined>
execute: (action: Extract<AgentAction, { action: 'tool' }>) => Promise<AgentToolResult>
onTrace: (item: Omit<AiSearchAgentTraceItem, 'sequence'>) => void
}
export interface ControlledSearchAgentResult {
status: 'finalized' | 'exhausted' | 'invalid'
toolCalls: number
reason: string
}
const TOOL_NAMES = new Set<AiSearchAgentToolName>([
'search_conversations',
'search_people',
'search_messages',
'get_conversation_messages',
'get_messages_by_time',
'get_message_context'
])
const parseAction = (value: string | undefined): AgentAction | null => {
if (!value) return null
const match = value.match(/\{[\s\S]*\}/)
if (!match) return null
try {
const parsed = JSON.parse(match[0]) as Record<string, unknown>
if (parsed.action === 'finalize' && typeof parsed.reason === 'string' && parsed.reason.trim()) {
return { action: 'finalize', reason: parsed.reason.trim().slice(0, 240) }
}
if (
parsed.action === 'tool' &&
typeof parsed.tool === 'string' &&
TOOL_NAMES.has(parsed.tool as AiSearchAgentToolName) &&
parsed.arguments &&
typeof parsed.arguments === 'object' &&
!Array.isArray(parsed.arguments)
) {
return {
action: 'tool',
tool: parsed.tool as AiSearchAgentToolName,
arguments: parsed.arguments as Record<string, unknown>
}
}
} catch {
// Invalid model output is rejected by the caller and triggers legacy fallback.
}
return null
}
const agentSystemPrompt = (
question: string,
scopeLabel: string,
rangeLabel: string
): string => `你是 WechatExplorer 的受控本地聊天搜索代理,只负责决定下一步检索,不回答用户问题。
用户问题:${question}
允许范围:${scopeLabel};时间范围:${rangeLabel}
你只能输出一个 JSON 对象,不能输出 Markdown、解释、代码、SQL、文件路径或任何系统操作。
唯一合法格式:
{"action":"tool","tool":"search_people|search_conversations|search_messages|get_conversation_messages|get_messages_by_time|get_message_context","arguments":{...}}
或:
{"action":"finalize","reason":"已有足够证据"}
规则:
- 只能使用此前 Tool 返回的 conversationRef/messageRef;不得猜测或创建引用。
- 问“我和某人最近聊了什么”时,优先 search_people 或 search_conversations,再 get_conversation_messages;不要把联系人名当消息关键词。
- 搜索会话没有结果时,可根据结果自行尝试更短或更自然的名称表达,但最多五次 Tool 调用。
- Tool 结果不足时可以改 Tool 或查询策略;结果充分时 finalize。
- 不要请求全部聊天记录;遵守 Tool 返回的受限结果。`
const traceArguments = (
argumentsValue: Record<string, unknown>
): Record<string, string | number | boolean> => {
const result: Record<string, string | number | boolean> = {}
if (typeof argumentsValue.query === 'string') result.query = argumentsValue.query.slice(0, 80)
if (typeof argumentsValue.limit === 'number') result.limit = argumentsValue.limit
if (typeof argumentsValue.startTime === 'number') result.startTime = argumentsValue.startTime
if (typeof argumentsValue.endTime === 'number') result.endTime = argumentsValue.endTime
if (typeof argumentsValue.conversationRef === 'string') result.target = '已选择会话'
if (typeof argumentsValue.messageRef === 'string') result.context = '已选择消息'
return result
}
export async function runControlledSearchAgent(
options: ControlledSearchAgentOptions
): Promise<ControlledSearchAgentResult> {
let toolCalls = 0
let previousResult = '尚未执行 Tool。'
options.onTrace({ event: 'agentStart', label: '开始规划本次本地检索' })
const maxToolCalls = options.maxToolCalls || MAX_AGENT_TOOL_CALLS
while (toolCalls < maxToolCalls) {
const decisionStartedAt = Date.now()
const decisionInput = `${agentSystemPrompt(options.question, options.scopeLabel, options.rangeLabel)}\n\n上一次 Tool 结果:${previousResult}`
const output = await options.decide(decisionInput)
const decisionElapsedMs = Date.now() - decisionStartedAt
const action = parseAction(output)
if (!action) return { status: 'invalid', toolCalls, reason: 'Agent 返回的控制协议无效' }
if (action.action === 'finalize') {
options.onTrace({
event: 'agentDecision',
label: 'Agent 判断现有结果足够',
decision: action.reason,
decisionInput: decisionInput.slice(0, 8_000),
elapsedMs: decisionElapsedMs
})
return { status: 'finalized', toolCalls, reason: action.reason }
}
options.onTrace({
event: 'agentDecision',
label: 'Agent 选择下一次检索',
toolName: action.tool,
elapsedMs: decisionElapsedMs,
decisionInput: decisionInput.slice(0, 8_000)
})
toolCalls += 1
options.onTrace({
event: 'toolCallStart',
label: '正在执行本地检索',
toolName: action.tool,
arguments: traceArguments(action.arguments)
})
const toolStartedAt = Date.now()
try {
const result = await options.execute(action)
const elapsedMs = Date.now() - toolStartedAt
options.onTrace({
event: 'toolCallEnd',
label: '本地检索完成',
toolName: action.tool,
resultCount: result.candidateCount,
elapsedMs
})
previousResult = JSON.stringify(result.summary)
if (result.finalizeReason) {
options.onTrace({
event: 'agentDecision',
label: '本地资料已覆盖所选时间范围,可直接整理回答',
decision: result.finalizeReason,
elapsedMs: 0
})
return { status: 'finalized', toolCalls, reason: result.finalizeReason }
}
} catch (error) {
const elapsedMs = Date.now() - toolStartedAt
const message = error instanceof Error ? error.message : '本次本地检索不可用'
options.onTrace({
event: 'toolCallEnd',
label: '本地检索未返回结果',
toolName: action.tool,
resultCount: 0,
elapsedMs,
decision: message.slice(0, 160)
})
previousResult = JSON.stringify({ error: message.slice(0, 160), results: [] })
}
}
options.onTrace({
event: 'agentDecision',
label: '已达到本次检索上限',
decision: `最多允许 ${maxToolCalls} 次本地检索`
})
return { status: 'exhausted', toolCalls, reason: '已达到本次检索上限' }
}
+217
View File
@@ -0,0 +1,217 @@
import type {
AiSearchAggregation,
AiSearchFinalEvidence,
AiSearchPipelineEvidence
} from '../../shared/ai-search'
export type EvidenceBuildResult = {
evidence: AiSearchFinalEvidence[]
aggregation: AiSearchAggregation
candidateCount: number
deduplicatedCount: number
candidateRankingMs: number
evidenceBuildMs: number
aggregationMs: number
}
export type CitationValidationResult = {
answer: string
invalidCitationIds: string[]
status: 'valid' | 'sanitized'
}
export const evidenceIdentity = (
item: Pick<AiSearchPipelineEvidence, 'conversationId' | 'messageId'>
): string => `${item.conversationId}\u0000${item.messageId}`
const compareEvidence = (left: AiSearchPipelineEvidence, right: AiSearchPipelineEvidence): number =>
(left.score ?? 0) - (right.score ?? 0) ||
right.timestamp - left.timestamp ||
evidenceIdentity(left).localeCompare(evidenceIdentity(right))
const personIdentity = (item: AiSearchFinalEvidence): string =>
item.senderId
? `sender:${item.senderId}`
: `conversation:${item.conversationId}:name:${item.sender}`
export function buildEvidenceAggregation(evidence: AiSearchFinalEvidence[]): AiSearchAggregation {
const people = new Map<
string,
{
id: string
name: string
messageCount: number
conversationIds: Set<string>
lastMessageAt: number
evidenceIds: AiSearchFinalEvidence['id'][]
}
>()
const conversations = new Map<
string,
{
id: string
name: string
type: 'user' | 'group'
messageCount: number
people: Set<string>
lastMessageAt: number
evidenceIds: AiSearchFinalEvidence['id'][]
}
>()
for (const item of evidence) {
const personId = personIdentity(item)
const person = people.get(personId) || {
id: personId,
name: item.sender,
messageCount: 0,
conversationIds: new Set<string>(),
lastMessageAt: item.timestamp,
evidenceIds: []
}
person.messageCount += 1
person.conversationIds.add(item.conversationId)
person.lastMessageAt = Math.max(person.lastMessageAt, item.timestamp)
person.evidenceIds.push(item.id)
people.set(personId, person)
const conversation = conversations.get(item.conversationId) || {
id: item.conversationId,
name: item.conversationName,
type: item.conversationType,
messageCount: 0,
people: new Set<string>(),
lastMessageAt: item.timestamp,
evidenceIds: []
}
conversation.messageCount += 1
conversation.people.add(personId)
conversation.lastMessageAt = Math.max(conversation.lastMessageAt, item.timestamp)
conversation.evidenceIds.push(item.id)
conversations.set(item.conversationId, conversation)
}
return {
messageCount: evidence.length,
peopleCount: people.size,
conversationCount: conversations.size,
people: Array.from(people.values())
.map((person) => ({
id: person.id,
name: person.name,
messageCount: person.messageCount,
conversationCount: person.conversationIds.size,
lastMessageAt: person.lastMessageAt,
evidenceIds: person.evidenceIds
}))
.sort(
(left, right) =>
right.messageCount - left.messageCount || right.lastMessageAt - left.lastMessageAt
),
conversations: Array.from(conversations.values())
.map((conversation) => ({
id: conversation.id,
name: conversation.name,
type: conversation.type,
messageCount: conversation.messageCount,
peopleCount: conversation.people.size,
lastMessageAt: conversation.lastMessageAt,
evidenceIds: conversation.evidenceIds
}))
.sort(
(left, right) =>
right.messageCount - left.messageCount || right.lastMessageAt - left.lastMessageAt
)
}
}
/**
* Performs all candidate ordering, identity de-duplication, final limiting and
* program-owned citation assignment in one place. Nothing downstream receives
* the candidate list as an AI context.
*/
export function buildFinalEvidence(
candidates: AiSearchPipelineEvidence[],
limit: number,
options?: { strategy?: 'ranked' | 'conversation_coverage' }
): EvidenceBuildResult {
const rankingStartedAt = Date.now()
const ranked = [...candidates].sort(compareEvidence)
const candidateRankingMs = Date.now() - rankingStartedAt
const evidenceStartedAt = Date.now()
const unique = new Map<string, AiSearchPipelineEvidence>()
for (const item of ranked) {
const identity = evidenceIdentity(item)
if (!unique.has(identity)) unique.set(identity, item)
}
const uniqueEvidence = Array.from(unique.values())
const selected =
options?.strategy === 'conversation_coverage'
? selectConversationCoverage(uniqueEvidence, limit)
: uniqueEvidence.slice(0, Math.max(1, limit))
const evidence = selected.map((item, index) => ({ ...item, id: `E${index + 1}` as const }))
const evidenceBuildMs = Date.now() - evidenceStartedAt
const aggregationStartedAt = Date.now()
const aggregation = buildEvidenceAggregation(evidence)
const aggregationMs = Date.now() - aggregationStartedAt
return {
evidence,
aggregation,
candidateCount: candidates.length,
deduplicatedCount: unique.size,
candidateRankingMs,
evidenceBuildMs,
aggregationMs
}
}
/**
* A recent-conversation answer should cover separate local conversation chunks,
* not merely pick eight adjacent newest messages from one exchange.
*/
function selectConversationCoverage(
evidence: AiSearchPipelineEvidence[],
limit: number
): AiSearchPipelineEvidence[] {
const max = Math.max(1, limit)
const byChunk = new Map<string, AiSearchPipelineEvidence[]>()
for (const item of evidence) {
const chunk = byChunk.get(item.chunkId) || []
chunk.push(item)
byChunk.set(item.chunkId, chunk)
}
const representatives = Array.from(byChunk.values())
.map((items) => [...items].sort(compareEvidence)[0])
.sort((left, right) => left.timestamp - right.timestamp)
if (representatives.length <= max) return representatives
const selected: AiSearchPipelineEvidence[] = []
for (let index = 0; index < max; index += 1) {
const position = Math.round((index * (representatives.length - 1)) / (max - 1 || 1))
const item = representatives[position]
if (item && !selected.includes(item)) selected.push(item)
}
return selected
}
/** Do not expose citations that cannot resolve to program-owned Final Evidence. */
export function sanitizeAnswerCitations(
answer: string,
evidence: Array<Pick<AiSearchFinalEvidence, 'id'>>
): CitationValidationResult {
const allowed = new Set(evidence.map((item) => item.id))
const invalidCitationIds = new Set<string>()
const sanitized = answer.replace(/\[E(\d+)\]/g, (citation, number: string) => {
const id = `E${number}`
if (allowed.has(id as AiSearchFinalEvidence['id'])) return citation
invalidCitationIds.add(id)
return ''
})
return {
answer: sanitized,
invalidCitationIds: Array.from(invalidCitationIds),
status: invalidCitationIds.size ? 'sanitized' : 'valid'
}
}
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -7,6 +7,11 @@ import type { CacheClearScope, CacheSummary, CacheSummaryItem } from '../../shar
export type { CacheClearScope } from '../../shared/cache'
const BOOTSTRAP_CACHE_DIR = path.join(app.getPath('userData'), 'cache', 'bootstrap')
const KNOWLEDGE_CACHE_DIR = path.join(app.getPath('userData'), 'knowledge')
export interface CacheClearOptions {
beforeClearKnowledge?: () => Promise<void>
}
function inspectDirectory(directory: string): { sizeBytes: number; fileCount: number } {
if (!fs.existsSync(directory)) return { sizeBytes: 0, fileCount: 0 }
@@ -40,6 +45,7 @@ function inspectDirectory(directory: string): { sizeBytes: number; fileCount: nu
export function getCacheSummary(): CacheSummary {
const bootstrap = inspectDirectory(BOOTSTRAP_CACHE_DIR)
const electron = inspectDirectory(path.join(app.getPath('userData'), 'Cache'))
const knowledge = inspectDirectory(KNOWLEDGE_CACHE_DIR)
const items: CacheSummaryItem[] = [
{
id: 'bootstrap',
@@ -52,6 +58,12 @@ export function getCacheSummary(): CacheSummary {
label: '应用临时缓存',
description: 'Electron 页面资源缓存,清理后会自动重新生成。',
...electron
},
{
id: 'knowledge',
label: '本地知识库索引',
description: '为问问微信建立的所有账号本地检索索引。清理后需手动重新建立,不影响微信原始数据。',
...knowledge
}
]
return {
@@ -61,7 +73,10 @@ export function getCacheSummary(): CacheSummary {
}
}
export async function clearCache(scope: CacheClearScope): Promise<CacheSummary> {
export async function clearCache(
scope: CacheClearScope,
options: CacheClearOptions = {}
): Promise<CacheSummary> {
if (scope === 'bootstrap' || scope === 'all') {
clearBootstrapCache()
await fs.remove(BOOTSTRAP_CACHE_DIR)
@@ -69,5 +84,9 @@ export async function clearCache(scope: CacheClearScope): Promise<CacheSummary>
if (scope === 'electron' || scope === 'all') {
await session.defaultSession.clearCache()
}
if (scope === 'knowledge' || scope === 'all') {
await options.beforeClearKnowledge?.()
await fs.remove(KNOWLEDGE_CACHE_DIR)
}
return getCacheSummary()
}
@@ -0,0 +1,86 @@
import type { Contact } from '../../shared/types'
import {
emptyContactResolution,
normalizeContactName,
type ContactResolutionCandidate,
type ContactResolutionMatch,
type ContactResolutionResult
} from '../../shared/contact-resolution'
export type ContactResolutionScope = 'any' | 'person' | 'group'
const displayName = (contact: Contact): string =>
contact.m_nsNickName || contact.remark || contact.wechatNickname || contact.m_nsUsrName
const aliases = (contact: Contact): Array<{ value: string; primary: boolean }> =>
[
{ value: contact.m_nsNickName, primary: true },
{ value: contact.remark || '', primary: false },
{ value: contact.wechatNickname || '', primary: false },
{ value: contact.m_nsUsrName, primary: false }
].filter((item) => Boolean(normalizeContactName(item.value)))
/**
* The one main-process authority that converts a user/Agent supplied name to
* an existing conversation. It only auto-confirms an exact canonical alias.
* Fuzzy discovery intentionally returns candidates rather than a guessed ID.
*/
export function resolveContact(
query: string,
contacts: Contact[],
scope: ContactResolutionScope = 'any'
): ContactResolutionResult {
const normalizedQuery = normalizeContactName(query)
if (!normalizedQuery) return emptyContactResolution()
const matches = new Map<string, { contact: Contact; matchedBy: ContactResolutionMatch }>()
for (const contact of contacts) {
if (!contact.md5) continue
if (scope === 'person' && contact.type !== 'user') continue
if (scope === 'group' && contact.type !== 'group') continue
for (const alias of aliases(contact)) {
if (normalizeContactName(alias.value) !== normalizedQuery) continue
const rawExact =
alias.value.trim().normalize('NFKC').toLocaleLowerCase() ===
query.trim().normalize('NFKC').toLocaleLowerCase()
const matchedBy: ContactResolutionMatch = rawExact
? 'exact'
: alias.primary
? 'normalized'
: 'alias'
const current = matches.get(contact.md5)
if (!current || (current.matchedBy === 'alias' && matchedBy !== 'alias')) {
matches.set(contact.md5, { contact, matchedBy })
}
}
}
const candidates: ContactResolutionCandidate[] = Array.from(matches.values())
.map(({ contact, matchedBy }) => ({
conversationId: contact.md5,
displayName: displayName(contact),
matchedBy,
confidence: 1
}))
.sort((left, right) => left.displayName.localeCompare(right.displayName, 'zh-CN'))
if (candidates.length !== 1) {
return {
...emptyContactResolution(),
candidates,
ambiguous: candidates.length > 1
}
}
const candidate = candidates[0]
const contact = matches.get(candidate.conversationId)!.contact
return {
matched: true,
personId: contact.m_nsUsrName,
conversationId: contact.md5,
canonicalName: displayName(contact),
displayName: candidate.displayName,
matchedBy: candidate.matchedBy,
confidence: candidate.confidence,
candidates,
ambiguous: false
}
}
+17 -1
View File
@@ -52,6 +52,16 @@ import type {
VoiceModelStatus,
VoiceRecognitionResult
} from '../shared/voice-recognition'
import type {
AiSearchPipelineRequest,
AiSearchPipelineResult,
AiSearchProgressEvent
} from '../shared/ai-search'
import type {
KnowledgeRuntimeStatus,
KnowledgeSearchIpcRequest,
KnowledgeSearchIpcResult
} from '../shared/knowledge'
export type ParsedContent =
| { type: 'text'; content: string }
@@ -121,7 +131,7 @@ declare global {
installAppUpdate: () => Promise<{ success: boolean; error?: string }>
onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void
getCacheSummary: () => Promise<CacheSummary>
clearCache: (scope: 'bootstrap' | 'electron' | 'all') => Promise<CacheSummary>
clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all') => Promise<CacheSummary>
initDb: (
key: string,
accountRoot: string
@@ -183,6 +193,12 @@ declare global {
}[]
} | null>
search: (keyword: string) => Promise<string | null>
searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise<KnowledgeSearchIpcResult>
runAiSearch: (request: AiSearchPipelineRequest) => Promise<AiSearchPipelineResult>
onAiSearchProgress: (callback: (progress: AiSearchProgressEvent) => void) => () => void
getKnowledgeStatus: () => Promise<KnowledgeRuntimeStatus>
startKnowledgeIndex: () => Promise<KnowledgeRuntimeStatus>
onKnowledgeStatus: (callback: (status: KnowledgeRuntimeStatus) => void) => () => void
aiChat: (
messages: { role: string; content: string }[],
options?: AIChatRequestOptions
+31 -1
View File
@@ -29,6 +29,16 @@ import type {
VoiceModelStatus,
VoiceRecognitionResult
} from '../shared/voice-recognition'
import type {
AiSearchPipelineRequest,
AiSearchPipelineResult,
AiSearchProgressEvent
} from '../shared/ai-search'
import type {
KnowledgeRuntimeStatus,
KnowledgeSearchIpcRequest,
KnowledgeSearchIpcResult
} from '../shared/knowledge'
// 渲染器的自定义 API
const api = {
@@ -46,7 +56,7 @@ const api = {
return () => ipcRenderer.removeListener('app-update:state', listener)
},
getCacheSummary: (): Promise<CacheSummary> => ipcRenderer.invoke('cache:getSummary'),
clearCache: (scope: 'bootstrap' | 'electron' | 'all'): Promise<CacheSummary> =>
clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all'): Promise<CacheSummary> =>
ipcRenderer.invoke('cache:clear', scope),
initDb: (key: string, accountRoot: string) => ipcRenderer.invoke('db:init', key, accountRoot),
discoverAccounts: (inputPath: string): Promise<AccountDiscoveryResult> =>
@@ -67,6 +77,26 @@ const api = {
) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options),
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
search: (keyword: string) => ipcRenderer.invoke('db:search', keyword),
searchKnowledge: (request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> =>
ipcRenderer.invoke('knowledge:search', request),
runAiSearch: (request: AiSearchPipelineRequest): Promise<AiSearchPipelineResult> =>
ipcRenderer.invoke('ai-search:run', request),
onAiSearchProgress: (callback: (progress: AiSearchProgressEvent) => void) => {
const listener = (_event: Electron.IpcRendererEvent, progress: AiSearchProgressEvent): void =>
callback(progress)
ipcRenderer.on('ai-search:progress', listener)
return () => ipcRenderer.removeListener('ai-search:progress', listener)
},
getKnowledgeStatus: (): Promise<KnowledgeRuntimeStatus> =>
ipcRenderer.invoke('knowledge:getStatus'),
startKnowledgeIndex: (): Promise<KnowledgeRuntimeStatus> =>
ipcRenderer.invoke('knowledge:startIndex'),
onKnowledgeStatus: (callback: (status: KnowledgeRuntimeStatus) => void) => {
const listener = (_event: Electron.IpcRendererEvent, status: KnowledgeRuntimeStatus): void =>
callback(status)
ipcRenderer.on('knowledge:status', listener)
return () => ipcRenderer.removeListener('knowledge:status', listener)
},
aiChat: (messages: { role: string; content: string }[], options?: AIChatRequestOptions) =>
ipcRenderer.invoke('ai:chat', messages, options),
listAIProviders: () => ipcRenderer.invoke('ai:listProviders'),
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,16 @@
import React from 'react'
const inlineMarkdown = (value: string, keyPrefix: string): React.ReactNode[] =>
value.split(/(\*\*.*?\*\*|`.*?`|\*.*?\*)/g).map((part, index) => {
type MarkdownOptions = {
evidenceCount?: number
onEvidenceClick?: (index: number) => void
}
const inlineMarkdown = (
value: string,
keyPrefix: string,
options: MarkdownOptions = {}
): React.ReactNode[] =>
value.split(/(\*\*.*?\*\*|`.*?`|\*.*?\*|\[E\d+\])/g).map((part, index) => {
const key = `${keyPrefix}-${index}`
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={key}>{part.slice(2, -2)}</strong>
@@ -12,24 +21,45 @@ const inlineMarkdown = (value: string, keyPrefix: string): React.ReactNode[] =>
if (part.startsWith('*') && part.endsWith('*')) {
return <em key={key}>{part.slice(1, -1)}</em>
}
const evidence = /^\[E(\d+)\]$/.exec(part)
if (evidence) {
const evidenceIndex = Number(evidence[1]) - 1
if (
evidenceIndex >= 0 &&
evidenceIndex < (options.evidenceCount || 0) &&
options.onEvidenceClick
) {
return (
<button
key={key}
type="button"
className="ai-search-inline-evidence"
onClick={() => options.onEvidenceClick?.(evidenceIndex)}
title={`查看证据 E${evidenceIndex + 1}`}
>
{part}
</button>
)
}
}
return <React.Fragment key={key}>{part}</React.Fragment>
})
export const renderMarkdown = (value: string): React.ReactNode =>
export const renderMarkdown = (value: string, options: MarkdownOptions = {}): React.ReactNode =>
value.split(/\r?\n/).map((line, index) => {
const key = `markdown-${index}`
if (!line.trim()) return <div key={key} className="ai-search-markdown-spacer" />
const heading = /^(#{1,3})\s+(.+)$/.exec(line)
if (heading) {
const Heading = `h${heading[1].length}` as 'h1' | 'h2' | 'h3'
return <Heading key={key}>{inlineMarkdown(heading[2], key)}</Heading>
return <Heading key={key}>{inlineMarkdown(heading[2], key, options)}</Heading>
}
const bullet = /^\s*[-*]\s+(.+)$/.exec(line)
if (bullet) {
return (
<div key={key} className="ai-search-markdown-list-item">
<span aria-hidden></span>
<span>{inlineMarkdown(bullet[1], key)}</span>
<span>{inlineMarkdown(bullet[1], key, options)}</span>
</div>
)
}
@@ -38,9 +68,9 @@ export const renderMarkdown = (value: string): React.ReactNode =>
return (
<div key={key} className="ai-search-markdown-list-item">
<span aria-hidden>{line.trim().match(/^\d+/)?.[0]}.</span>
<span>{inlineMarkdown(numbered[1], key)}</span>
<span>{inlineMarkdown(numbered[1], key, options)}</span>
</div>
)
}
return <p key={key}>{inlineMarkdown(line, key)}</p>
return <p key={key}>{inlineMarkdown(line, key, options)}</p>
})
@@ -1,12 +1,14 @@
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
import type { Contact, Message } from '../../../../shared/types'
export type SearchStage = 'idle' | 'loading' | 'result' | 'insufficient'
export type SearchScope = 'global' | 'conversation'
export type SearchStage = 'idle' | 'loading' | 'result' | 'partial' | 'insufficient'
export type SearchScope = 'global' | 'groups' | 'contacts' | 'conversation'
export type SearchRange = 'today' | '7d' | '30d' | 'all'
export type SearchIntent = 'general' | 'topic' | 'participants' | 'mixed'
export interface EvidenceItem {
/** Program-owned Final Evidence ID. Cached legacy records may omit it. */
evidenceId?: string
contact: Contact
message: Message
}
@@ -8,7 +8,9 @@ export const RANGE_LABELS: Record<SearchRange, string> = {
all: '全部历史'
}
export const SEARCH_CACHE_KEY = 'wxe_ai_search_cache_v8'
// Final Evidence IDs are now program-owned; never replay answers cached under
// the former candidate-context contract.
export const SEARCH_CACHE_KEY = 'wxe_ai_search_cache_v9'
export const SEARCH_HISTORY_KEY = 'wxe_ai_search_history_v1'
export const SEARCH_CACHE_LIMIT = 20
export const currentTimestamp = (): number => Date.now()
@@ -270,7 +272,8 @@ export const senderName = (message: Message, contact: Contact, names: Record<str
return contact.type === 'user' ? contact.m_nsNickName || '联系人' : '群成员'
}
export const compactCacheItem = ({ contact, message }: EvidenceItem): EvidenceItem => ({
export const compactCacheItem = ({ evidenceId, contact, message }: EvidenceItem): EvidenceItem => ({
evidenceId,
contact: {
md5: contact.md5,
m_nsUsrName: contact.m_nsUsrName,
@@ -12,7 +12,9 @@ function formatBytes(value: number): string {
export function CacheCleanupPage({ onNotice }: { onNotice: (message: string) => void }): React.ReactElement {
const [summary, setSummary] = useState<CacheSummary | null>(null)
const [busyScope, setBusyScope] = useState<'bootstrap' | 'electron' | 'all' | 'local' | null>(null)
const [busyScope, setBusyScope] = useState<
'bootstrap' | 'electron' | 'knowledge' | 'all' | 'local' | null
>(null)
const refresh = useCallback(async (): Promise<void> => {
setSummary(await window.api.getCacheSummary())
@@ -29,14 +31,22 @@ export function CacheCleanupPage({ onNotice }: { onNotice: (message: string) =>
onNotice('已清理检索和导出本地缓存')
}
const clear = async (scope: 'bootstrap' | 'electron' | 'all'): Promise<void> => {
const clear = async (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all'): Promise<void> => {
setBusyScope(scope)
try {
setSummary(await window.api.clearCache(scope))
if (scope === 'all') {
for (const key of SEARCH_CACHE_KEYS) localStorage.removeItem(key)
}
setSummary(await window.api.clearCache(scope))
onNotice(scope === 'all' ? '已清理全部可恢复缓存和检索记录' : '缓存已清理')
onNotice(
scope === 'knowledge'
? '已清理所有账号的本地知识库索引,需要时可在问问微信中重新建立'
: scope === 'all'
? '已清理全部可恢复缓存和检索记录'
: '缓存已清理'
)
} catch (error) {
onNotice(error instanceof Error ? error.message : '清理缓存失败')
} finally {
setBusyScope(null)
}
+718 -18
View File
@@ -118,6 +118,9 @@
}
.ai-search-scope-panel {
display: flex;
flex-direction: column;
gap: 20px;
padding: 16px 12px;
border-right: 1px solid var(--wxex-border);
}
@@ -152,7 +155,252 @@
font-weight: 700;
line-height: 20px;
}
}
.ai-search-header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.ai-search-knowledge-pill {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 10px;
border: 1px solid var(--wxex-border);
border-radius: 999px;
background: var(--wxex-bg-elevated);
color: var(--wxex-text-secondary);
font-size: 11px;
white-space: nowrap;
}
.ai-search-knowledge-dot {
width: 7px;
height: 7px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--wxex-text-muted);
}
.ai-search-knowledge-card.building .ai-search-knowledge-dot,
.ai-search-knowledge-card.syncing .ai-search-knowledge-dot {
background: var(--wxex-warning);
}
.ai-search-knowledge-card.ready .ai-search-knowledge-dot {
background: var(--wxex-success);
}
.ai-search-knowledge-card.error .ai-search-knowledge-dot {
background: var(--wxex-danger, #c15d4d);
}
.ai-search-filter-section {
flex: 0 0 auto;
}
.ai-search-secondary-menu,
.ai-search-time-menu {
display: flex;
flex-direction: column;
gap: 3px;
}
.ai-search-secondary-menu button,
.ai-search-time-menu button {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
min-height: 34px;
padding: 7px 9px;
border: 0;
border-radius: var(--wxex-radius-sm);
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: inherit;
font-size: 11px;
text-align: left;
}
.ai-search-secondary-menu button:hover,
.ai-search-time-menu button:hover,
.ai-search-secondary-menu button.active,
.ai-search-time-menu button.active {
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-weight: 700;
}
.ai-search-secondary-menu button:disabled {
color: var(--wxex-text-muted);
cursor: not-allowed;
opacity: 0.65;
}
.ai-search-secondary-menu button span,
.ai-search-time-menu button span {
width: 18px;
color: currentColor;
font-size: 14px;
text-align: center;
}
.ai-search-time-section {
padding-bottom: 3px;
border-bottom: 1px solid var(--wxex-border);
}
.ai-search-knowledge-card {
display: grid;
gap: 10px;
margin-top: auto;
padding: 12px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
}
.ai-search-knowledge-card.building,
.ai-search-knowledge-card.syncing {
border-color: color-mix(in srgb, var(--wxex-brand) 55%, var(--wxex-border));
}
.ai-search-knowledge-card.ready {
border-color: color-mix(in srgb, var(--wxex-success) 55%, var(--wxex-border));
}
.ai-search-knowledge-card.error {
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;
}
.ai-search-knowledge-card-heading > div {
display: flex;
flex-direction: column;
gap: 2px;
}
.ai-search-knowledge-card-heading span:first-child {
color: var(--wxex-text-muted);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
}
.ai-search-knowledge-card-heading strong {
color: var(--wxex-text-primary);
font-size: 12px;
}
.ai-search-knowledge-description,
.ai-search-knowledge-error,
.ai-search-knowledge-more p {
margin: 0;
color: var(--wxex-text-muted);
font-size: 10px;
line-height: 15px;
}
.ai-search-knowledge-error {
color: var(--wxex-warning);
}
.ai-search-sync-progress {
display: grid;
gap: 5px;
}
.ai-search-sync-progress-top {
color: var(--wxex-text-secondary);
font-size: 10px;
}
.ai-search-sync-progress-track {
height: 5px;
overflow: hidden;
border-radius: 999px;
background: var(--wxex-border);
}
.ai-search-sync-progress-track span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--wxex-brand);
transition: width 250ms ease;
}
.ai-search-knowledge-card.building .ai-search-sync-progress-track span,
.ai-search-knowledge-card.syncing .ai-search-sync-progress-track span {
animation: ai-search-sync-pulse 1.6s ease-in-out infinite;
}
@keyframes ai-search-sync-pulse {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
.ai-search-knowledge-details {
display: grid;
gap: 5px;
padding-top: 2px;
}
.ai-search-knowledge-details span {
color: var(--wxex-text-muted);
font-size: 10px;
}
.ai-search-knowledge-details strong {
color: var(--wxex-text-secondary);
font-size: 10px;
font-weight: 600;
}
.ai-search-knowledge-action {
min-height: 30px;
padding: 6px 9px;
border: 1px solid var(--wxex-brand);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-brand);
color: #fff;
cursor: pointer;
font: inherit;
font-size: 11px;
font-weight: 700;
}
.ai-search-knowledge-action:disabled {
cursor: wait;
opacity: 0.65;
}
.ai-search-knowledge-more {
color: var(--wxex-text-secondary);
font-size: 10px;
}
.ai-search-knowledge-more summary {
cursor: pointer;
color: var(--wxex-brand);
font-weight: 700;
}
.ai-search-local-badge,
@@ -566,10 +814,156 @@
}
}
.ai-search-loading {
align-items: flex-start;
justify-content: flex-start;
padding-top: clamp(34px, 8vh, 88px);
text-align: left;
> h2,
> p {
width: 100%;
max-width: none;
}
}
.ai-search-pipeline {
width: 100%;
display: grid;
gap: 0;
margin-top: 24px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
overflow: hidden;
}
.ai-search-pipeline-step {
position: relative;
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--wxex-border);
color: var(--wxex-text-muted);
&:last-child {
border-bottom: 0;
}
> div {
min-width: 0;
}
strong {
display: block;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
}
p {
max-width: none;
margin-top: 2px;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 17px;
}
&.done strong {
color: var(--wxex-success);
}
&.active {
background: color-mix(in srgb, var(--wxex-brand-soft) 58%, transparent);
strong,
p {
color: var(--wxex-brand);
}
}
&.error {
background: #fff8ef;
strong,
p {
color: var(--wxex-warning);
}
}
}
.ai-search-pipeline-mark {
width: 22px;
height: 22px;
display: grid;
place-items: center;
margin-top: 1px;
border: 1px solid var(--wxex-border);
border-radius: 50%;
background: var(--wxex-bg-main);
color: var(--wxex-text-muted);
font-size: 11px;
font-weight: 700;
}
.ai-search-pipeline-step.done .ai-search-pipeline-mark {
border-color: color-mix(in srgb, var(--wxex-success) 45%, transparent);
background: color-mix(in srgb, var(--wxex-success) 11%, transparent);
color: var(--wxex-success);
}
.ai-search-pipeline-step.active .ai-search-pipeline-mark {
border-color: var(--wxex-brand);
color: var(--wxex-brand);
animation: ai-search-pipeline-pulse 1.2s ease-in-out infinite;
}
.ai-search-pipeline-step.error .ai-search-pipeline-mark {
border-color: var(--wxex-warning);
background: #fff1df;
color: var(--wxex-warning);
}
@keyframes ai-search-pipeline-pulse {
50% {
transform: scale(1.08);
box-shadow: 0 0 0 5px color-mix(in srgb, var(--wxex-brand) 10%, transparent);
}
}
.ai-search-pipeline-details {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 7px;
span {
padding: 2px 6px;
border-radius: 4px;
background: var(--wxex-bg-sidebar);
color: var(--wxex-text-secondary);
font-size: 10px;
line-height: 15px;
}
}
.ai-search-result {
width: min(820px, calc(100% - 48px));
margin: 0 auto;
padding: 28px 0 34px;
animation: ai-search-result-in 260ms ease-out both;
}
@keyframes ai-search-result-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ai-search-result-header {
@@ -617,6 +1011,41 @@
.ai-search-summary-block {
margin-top: 22px;
animation: ai-search-result-in 340ms 45ms ease-out both;
}
.ai-search-answer > * {
animation: ai-search-answer-line-in 260ms ease-out both;
}
.ai-search-answer > *:nth-child(1) {
animation-delay: 70ms;
}
.ai-search-answer > *:nth-child(2) {
animation-delay: 105ms;
}
.ai-search-answer > *:nth-child(3) {
animation-delay: 140ms;
}
.ai-search-answer > *:nth-child(4) {
animation-delay: 175ms;
}
.ai-search-answer > *:nth-child(5) {
animation-delay: 210ms;
}
.ai-search-answer > *:nth-child(n + 6) {
animation-delay: 245ms;
}
@keyframes ai-search-answer-line-in {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ai-search-section-heading {
@@ -647,6 +1076,176 @@
white-space: pre-wrap;
}
.ai-search-answer-evidence,
.ai-search-trace {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
color: var(--wxex-text-muted);
font-size: 10px;
line-height: 16px;
}
.ai-search-answer-evidence {
padding: 9px 12px;
border: 1px solid var(--wxex-border);
border-top: 0;
background: var(--wxex-bg-elevated);
}
.ai-search-answer-evidence button {
padding: 2px 6px;
border: 1px solid var(--wxex-ai);
border-radius: 4px;
background: transparent;
color: var(--wxex-ai);
cursor: pointer;
font: inherit;
font-weight: 700;
}
.ai-search-inline-evidence {
display: inline-flex;
align-items: center;
min-height: 22px;
margin: 0 2px;
padding: 1px 6px;
border: 1px solid color-mix(in srgb, var(--primary-color) 35%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
color: var(--primary-color);
font: inherit;
font-size: 0.85em;
line-height: 1;
cursor: pointer;
}
.ai-search-inline-evidence:hover {
background: color-mix(in srgb, var(--primary-color) 18%, transparent);
}
.ai-search-trace {
margin-top: 9px;
}
.ai-search-trace span {
padding: 2px 5px;
border-radius: 4px;
background: var(--wxex-bg-sidebar);
}
.ai-search-details {
margin-top: 12px;
color: var(--wxex-text-secondary);
font-size: 11px;
summary {
width: fit-content;
color: var(--wxex-brand);
cursor: pointer;
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
summary::after {
content: '';
}
&[open] summary::after {
content: '';
}
}
.ai-search-details-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
section {
display: grid;
gap: 3px;
padding: 9px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-sidebar);
}
strong {
margin-bottom: 2px;
color: var(--wxex-text-primary);
font-size: 10px;
}
span {
color: var(--wxex-text-muted);
font-size: 10px;
line-height: 15px;
}
}
.ai-search-partial {
.ai-search-details {
width: 100%;
max-width: 640px;
margin-top: 18px;
text-align: left;
}
}
.ai-search-knowledge-status {
display: grid;
gap: 4px;
margin: 0 0 16px;
padding: 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
}
.ai-search-knowledge-status > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ai-search-knowledge-status > div span,
.ai-search-knowledge-status p,
.ai-search-knowledge-status small {
margin: 0;
color: var(--wxex-text-muted);
font-size: 10px;
line-height: 15px;
}
.ai-search-knowledge-status > div span {
font-weight: 700;
letter-spacing: 0.04em;
}
.ai-search-knowledge-status strong {
color: var(--wxex-text-primary);
font-size: 11px;
}
.ai-search-knowledge-status.building,
.ai-search-knowledge-status.syncing {
border-color: var(--wxex-ai);
}
.ai-search-knowledge-status.ready strong {
color: var(--wxex-success);
}
.ai-search-knowledge-status.error {
border-color: var(--wxex-warning);
}
.ai-search-insufficient-icon {
width: 44px;
height: 44px;
@@ -661,6 +1260,7 @@
}
.ai-search-composer {
position: relative;
padding: 12px 18px 14px;
border-top: 1px solid var(--wxex-border);
background: var(--wxex-bg-elevated);
@@ -688,6 +1288,98 @@
}
}
.ai-search-history-trigger {
margin-left: auto;
padding: 3px 7px !important;
border: 1px solid var(--wxex-border) !important;
border-radius: 999px !important;
background: var(--wxex-bg-main) !important;
color: var(--wxex-brand) !important;
cursor: pointer;
font-size: 10px !important;
}
.ai-search-history-popover {
z-index: 30;
width: min(420px, calc(100% - 36px));
max-height: 236px;
overflow: auto;
padding: 9px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
box-shadow: 0 10px 28px rgba(31, 52, 45, 0.16);
transform-origin: var(--radix-popover-content-transform-origin);
animation: ai-search-history-popover-in 150ms ease-out both;
}
@keyframes ai-search-history-popover-in {
from {
opacity: 0;
transform: translateY(5px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.ai-search-history-popover-heading,
.ai-search-history-popover-item {
display: flex;
align-items: center;
gap: 7px;
}
.ai-search-history-popover-heading {
justify-content: space-between;
padding: 2px 3px 7px;
color: var(--wxex-text-primary);
font-size: 11px;
}
.ai-search-history-popover-heading button,
.ai-search-history-popover-item > button:last-child {
width: 22px;
height: 22px;
flex: 0 0 auto;
padding: 0 !important;
border: 0 !important;
background: transparent !important;
color: var(--wxex-text-muted) !important;
cursor: pointer;
font-size: 16px !important;
line-height: 20px;
}
.ai-search-history-popover-item > button:first-child {
flex: 1;
min-width: 0;
padding: 7px 6px !important;
overflow: hidden;
border: 0 !important;
border-radius: var(--wxex-radius-sm) !important;
background: transparent !important;
color: var(--wxex-text-secondary) !important;
cursor: pointer;
font: inherit;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-search-history-popover-item > button:hover {
background: var(--wxex-brand-soft) !important;
color: var(--wxex-brand) !important;
}
.ai-search-history-empty {
display: block;
padding: 8px 4px 3px;
color: var(--wxex-text-muted);
font-size: 10px;
}
.ai-search-composer-row {
display: flex;
align-items: flex-end;
@@ -743,24 +1435,6 @@
margin-top: 6px;
}
.ai-search-evidence-meta {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
gap: 4px 8px;
margin-bottom: 12px;
color: var(--wxex-text-muted);
font-size: 10px;
line-height: 15px;
}
.ai-search-evidence-meta strong {
overflow: hidden;
color: var(--wxex-text-secondary);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-search-evidence-card {
display: block;
width: 100%;
@@ -773,6 +1447,18 @@
cursor: pointer;
font: inherit;
text-align: left;
animation: ai-search-evidence-in 240ms ease-out both;
}
@keyframes ai-search-evidence-in {
from {
opacity: 0;
transform: translateX(7px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.ai-search-evidence-card:hover,
@@ -781,6 +1467,20 @@
background: #f4fbf8;
}
@media (prefers-reduced-motion: reduce) {
.ai-search-spinner,
.ai-search-result,
.ai-search-summary-block,
.ai-search-answer > *,
.ai-search-evidence-card,
.ai-search-history-popover,
.ai-search-pipeline-step.active .ai-search-pipeline-mark,
.ai-search-sync-progress-track span {
animation: none !important;
transition: none !important;
}
}
.ai-search-evidence-card-top,
.ai-search-evidence-conversation,
.ai-search-evidence-text,
+625
View File
@@ -0,0 +1,625 @@
import type { KnowledgeEvidence, KnowledgeSearchIpcResult } from './knowledge'
export type AiSearchScope = 'global' | 'groups' | 'contacts' | 'conversation'
export type AiSearchRange = 'today' | '7d' | '30d' | 'all'
/**
* Retrieval semantics, not presentation labels. Each intent has a constrained
* execution path in the main process; a model must not be able to quietly turn
* an identity lookup into a generic message-keyword search.
*/
export type AiSearchIntent =
| 'conversation_recall'
| 'conversation_topic_search'
| 'global_topic_search'
| 'conversation_name_search'
| 'general'
export interface AiSearchTimeRange {
/** Unix seconds. Undefined start means the user explicitly allowed all history. */
startTime?: number
endTime?: number
label: string
reason: string
source: 'ui' | 'query' | 'user_retry'
}
export type AiSearchProgressStage =
| 'query_understanding'
| 'agent_start'
| 'agent_tool'
| 'agent_decision'
| 'search_plan_ready'
| 'knowledge_searching'
| 'evidence_ranking'
| 'evidence_ready'
| 'aggregation'
| 'ai_generating'
| 'completed'
| 'error'
export type AiSearchProgressStatus = 'running' | 'completed' | 'error'
export interface AiSearchPlan {
intent: AiSearchIntent
keywords: string[]
variants: string[]
source: 'local' | 'ai' | 'hybrid'
scopeLabel: string
rangeLabel: string
timeRange: AiSearchTimeRange
contactNames: string[]
/** A user-supplied identity candidate. It must be resolved by Contact Resolution. */
contactQuery?: string
/** The message-content query, never a contact display name. */
topicQuery?: string
}
export interface AiSearchPipelineRequest {
requestId: string
text: string
scope: AiSearchScope
range: AiSearchRange
conversationId?: string
/** Explicit user retry takes precedence over natural-language inference. */
timeRangeOverride?: AiSearchTimeRange
}
export interface AiSearchProgressEvent {
requestId: string
stage: AiSearchProgressStage
status: AiSearchProgressStatus
message: string
plan?: AiSearchPlan
stats?: {
knowledgeMessageCount?: number
matchedMessages?: number
evidenceCount?: number
contextEvidenceCount?: number
tokenEstimate?: number
inputTokens?: number
inputTokensEstimated?: boolean
elapsedMs?: number
deduplicatedMessages?: number
peopleCount?: number
conversationCount?: number
}
timings?: AiSearchPipelineTimings
modelName?: string
agentTrace?: AiSearchAgentTraceItem
error?: string
}
export type AiSearchAgentToolName =
| 'search_conversations'
| 'search_people'
| 'search_messages'
| 'get_conversation_messages'
| 'get_messages_by_time'
| 'get_message_context'
export type AiSearchAgentTraceEvent =
| 'agentStart'
| 'toolCallStart'
| 'toolCallEnd'
| 'agentDecision'
| 'evidenceBuild'
| 'summaryStart'
| 'summaryEnd'
| 'fallback'
/** Public trace: deliberately contains no SQL, paths, raw IDs, or Worker details. */
export interface AiSearchAgentTraceItem {
sequence: number
event: AiSearchAgentTraceEvent
label: string
toolName?: AiSearchAgentToolName
/** Sanitized, human-readable arguments only. */
arguments?: Record<string, string | number | boolean>
resultCount?: number
elapsedMs?: number
decision?: string
/** Bounded local snapshot of the exact decision prompt; never sent to analytics. */
decisionInput?: string
}
export interface AiSearchAgentRun {
mode: 'agent' | 'fallback'
toolCalls: number
trace: AiSearchAgentTraceItem[]
fallbackReason?: string
}
export interface AiSearchPipelineEvidence extends KnowledgeEvidence {
conversationName: string
conversationType: 'user' | 'group'
}
/** A program-generated, stable citation. This is the only Evidence shape sent to AI/UI. */
export interface AiSearchFinalEvidence extends AiSearchPipelineEvidence {
id: `E${number}`
}
export interface AiSearchPersonAggregation {
id: string
name: string
messageCount: number
conversationCount: number
lastMessageAt: number
evidenceIds: Array<`E${number}`>
}
export interface AiSearchConversationAggregation {
id: string
name: string
type: 'user' | 'group'
messageCount: number
peopleCount: number
lastMessageAt: number
evidenceIds: Array<`E${number}`>
}
export interface AiSearchAggregation {
messageCount: number
peopleCount: number
conversationCount: number
people: AiSearchPersonAggregation[]
conversations: AiSearchConversationAggregation[]
}
/** All fields are directly measured around real work. */
export interface AiSearchPipelineTimings {
queryUnderstandingMs: number
contactResolutionMs: number
knowledgeSearchMs: number
workerIpcMs: number
workerBootMs: number
dispatchMs: number
workerSqlMs: number
responseSerializeMs: number
responseTransferMs: number
ftsMs: number
chunkExpandMs: number
messageLoadMs: number
rankingMs: number
candidateRankingMs: number
evidenceBuildMs: number
aggregationMs: number
contextPreparationMs: number
agentDecisionMs: number
agentToolMs: number
aiGenerationMs: number
totalMs: number
}
export interface AiSearchCitationValidation {
status: 'valid' | 'sanitized'
invalidCitationIds: string[]
}
/** Truthful retrieval metadata shared by the AI, UI and diagnostics. */
export interface AiSearchRetrievalContract {
intent: AiSearchIntent
conversationId?: string
timeRange: AiSearchTimeRange
retrievalMode:
| 'conversation_metadata'
| 'conversation_topic_fts'
| 'global_fts'
| 'conversation_name'
| 'unresolved_identity'
candidateCount: number
sourceMessageCount?: number
sourceCoverage: 'complete' | 'partial' | 'keyword_match' | 'unknown'
isComplete: boolean
fallbackUsed: boolean
fallbackReason?: string
suspicious: boolean
}
export interface AiSearchPipelineResult {
requestId: string
status: 'completed' | 'no_evidence' | 'retrieval_incomplete' | 'ai_failed' | 'failed'
plan: AiSearchPlan
knowledge: Pick<
KnowledgeSearchIpcResult,
| 'source'
| 'state'
| 'fallbackReason'
| 'indexedMessageCount'
| 'indexedChunkCount'
| 'totalMessages'
>
candidateEvidenceCount: number
retrieval: AiSearchRetrievalContract
evidence: AiSearchFinalEvidence[]
contextEvidenceCount: number
aggregation: AiSearchAggregation
agent: AiSearchAgentRun
citationValidation?: AiSearchCitationValidation
timings: AiSearchPipelineTimings
answer?: string
ai?: {
providerName: string
modelName: string
inputTokens?: number
inputTokensEstimated: boolean
}
error?: string
errorStage?: Exclude<AiSearchProgressStage, 'completed' | 'error'>
elapsedMs: number
}
const RANGE_LABELS: Record<AiSearchRange, string> = {
today: '今天',
'7d': '近 7 天',
'30d': '近 30 天',
all: '全部历史'
}
const SEARCH_INTENT_PHRASES = [
'全局搜一下',
'全局搜索',
'搜索一下',
'搜一下',
'查询一下',
'查一下',
'找一下',
'我和谁聊过',
'谁和我聊过',
'谁聊过',
'哪些人和我聊过',
'最近讨论了什么',
'最近聊了什么',
'最近说了什么',
'讨论了什么',
'讨论什么',
'聊了什么',
'聊些什么',
'说了什么',
'说些什么',
'最近讨论',
'最近聊天',
'这个话题',
'相关话题',
'的聊天',
'的内容',
'的记录',
'关于',
'聊天',
'记录',
'聊天记录',
'帮我',
'请问',
'最近'
].sort((left, right) => right.length - left.length)
const SEARCH_STOP_WORDS = new Set([
'我',
'谁',
'什么',
'哪些',
'哪个',
'人',
'和',
'聊过',
'说过',
'提到',
'讨论',
'聊天',
'记录',
'说',
'聊',
'话题',
'内容',
'相关',
'最近',
'一下'
])
export const aiSearchRangeLabel = (range: AiSearchRange): string => RANGE_LABELS[range]
export const aiSearchRangeStart = (range: AiSearchRange): number | undefined => {
if (range === 'all') return undefined
if (range === 'today') {
const now = new Date()
return Math.floor(new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000)
}
return Math.floor(Date.now() / 1000) - (range === '7d' ? 7 : 30) * 86400
}
const dayStart = (date: Date): number =>
Math.floor(new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000)
const currentYearStart = (date: Date): number =>
Math.floor(new Date(date.getFullYear(), 0, 1).getTime() / 1000)
const currentMonthStart = (date: Date): number =>
Math.floor(new Date(date.getFullYear(), date.getMonth(), 1).getTime() / 1000)
const CHINESE_NUMBERS: Record<string, number> = {
: 1,
: 2,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 8,
: 9,
: 10
}
const parseNaturalNumber = (value: string | undefined): number | undefined => {
if (!value) return undefined
const numeric = Number(value)
if (Number.isFinite(numeric)) return numeric
return CHINESE_NUMBERS[value]
}
/**
* Query time expressions are part of SearchPlan, never a renderer-only rule.
* A natural-language time constraint is more specific than the broad "all" UI scope.
*/
export const inferAiSearchTimeRange = (
query: string,
uiRange: AiSearchRange,
now = new Date(),
override?: AiSearchTimeRange
): AiSearchTimeRange => {
if (override?.source === 'user_retry') return override
const nowSeconds = Math.floor(now.getTime() / 1000)
const fromQuery = (startTime: number, label: string, reason: string): AiSearchTimeRange => ({
startTime,
endTime: nowSeconds,
label,
reason,
source: 'query'
})
const recentDays = query.match(/最近\s*(\d{1,3}|[一二两三四五六七八九十])\s*天/)
if (recentDays) {
const days = Math.max(1, Math.min(365, parseNaturalNumber(recentDays[1]) || 30))
return fromQuery(nowSeconds - days * 86400, `${days}`, `用户说“最近 ${days} 天”`)
}
const recentMonths = query.match(/最近\s*(\d{1,2}|[一二两三四五六七八九十])\s*个?月/)
if (recentMonths) {
const months = Math.max(1, Math.min(24, parseNaturalNumber(recentMonths[1]) || 1))
const start = new Date(now.getFullYear(), now.getMonth() - months, now.getDate()).getTime()
return fromQuery(Math.floor(start / 1000), `${months} 个月`, `用户说“最近 ${months} 个月”`)
}
if (/刚刚|刚才/.test(query))
return fromQuery(nowSeconds - 24 * 3600, '近 24 小时', '用户说“刚刚”')
if (/这几天/.test(query)) return fromQuery(nowSeconds - 7 * 86400, '近 7 天', '用户说“这几天”')
if (/这周|本周/.test(query)) {
const weekday = now.getDay() || 7
return fromQuery(dayStart(now) - (weekday - 1) * 86400, '本周', '用户说“这周”')
}
if (/这个月|本月/.test(query)) return fromQuery(currentMonthStart(now), '本月', '用户说“这个月”')
if (/上个月/.test(query)) {
const start = Math.floor(new Date(now.getFullYear(), now.getMonth() - 1, 1).getTime() / 1000)
const end = Math.floor(new Date(now.getFullYear(), now.getMonth(), 1).getTime() / 1000) - 1
return {
startTime: start,
endTime: end,
label: '上个月',
reason: '用户说“上个月”',
source: 'query'
}
}
if (/今年/.test(query)) return fromQuery(currentYearStart(now), '今年', '用户说“今年”')
if (/最近/.test(query)) return fromQuery(nowSeconds - 30 * 86400, '近 30 天', '用户说“最近”')
return {
startTime: aiSearchRangeStart(uiRange),
endTime: undefined,
label: aiSearchRangeLabel(uiRange),
reason: '使用界面选择的时间范围',
source: 'ui'
}
}
export const aiSearchIntentLabel = (intent: AiSearchIntent): string => {
if (intent === 'conversation_recall') return '回顾最近聊天'
if (intent === 'conversation_topic_search') return '在指定聊天中查找话题'
if (intent === 'global_topic_search') return '按话题查找'
if (intent === 'conversation_name_search') return '查找聊天'
return '综合查找'
}
export const aiSearchScopeLabel = (scope: AiSearchScope, conversationName?: string): string => {
if (scope === 'groups') return '群聊'
if (scope === 'contacts') return '单聊'
if (scope === 'conversation') return conversationName || '当前会话'
return '所有聊天'
}
const normalizeTerms = (terms: unknown): string[] => {
if (!Array.isArray(terms)) return []
return Array.from(
new Set(
terms
.filter((term): term is string => typeof term === 'string')
.map((term) => term.trim())
.filter((term) => term.length >= 2 && term.length <= 32)
)
).slice(0, 16)
}
const extractKeywords = (query: string): string[] => {
const cleaned = SEARCH_INTENT_PHRASES.reduce(
(value, phrase) => value.split(phrase).join(' '),
query.toLowerCase()
)
return Array.from(
new Set(
cleaned
.split(/[\s,,。!?!?、:;"“”‘’()()[\]【】]+/)
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !SEARCH_STOP_WORDS.has(token))
)
)
}
const keywordVariants = (keywords: string[]): string[] =>
Array.from(
new Set(
keywords.flatMap((keyword) => {
const variants = [keyword]
if (/^[\u4e00-\u9fff]+$/.test(keyword) && keyword.length > 2) {
variants.push(keyword.slice(-2))
}
return variants
})
)
)
export const buildLocalAiSearchPlan = (
query: string
): Pick<
AiSearchPlan,
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery'
> => {
const keywords = extractKeywords(query)
const normalized = query.replace(/[“”"'‘’「」『』]/g, '').trim()
const recall = normalized.match(
/(?:我和|我跟|我与)\s*(.+?)\s*(?:最近|这几天|本周|这个月|本月|今年|上个月|刚刚|刚才)?\s*(?:聊了什么|聊过什么|说了什么|谈了什么)/
)
const reverseRecall = normalized.match(
/^\s*(.+?)\s*(?:最近)?(?:跟我|和我|与我)\s*(?:聊了什么|聊过什么|说了什么|谈了什么)/
)
const namedConversationRecall = normalized.match(
/(?:我在|在)\s*(.+?)\s*(?:最近)?\s*(?:聊了什么|聊过什么|说了什么|谈了什么)/
)
const conversationTopic = normalized.match(
/(?:我和|我跟|我与)\s*(.+?)\s*(?:最近|这几天|本周|这个月|本月|今年|上个月)?\s*(?:聊过|提过|说过|讨论过)\s*(.+?)(?:吗|么|沒有|没有)?[?。!!]*$/
)
const globalTopic = normalized.match(
/(?:最近|这几天|本周|这个月|本月|今年)?\s*(?:谁|哪些人|大家)\s*(?:聊过|提过|说过|讨论过)\s*(.+?)[?。!!]*$/
)
const conversationName =
!recall &&
!reverseRecall &&
!conversationTopic &&
!namedConversationRecall &&
!globalTopic &&
/^[^,。!?!?]{2,32}(?:群|群聊|交流群)$/.test(normalized)
? normalized
: undefined
const contactQuery = (
conversationTopic?.[1] ||
recall?.[1] ||
reverseRecall?.[1] ||
namedConversationRecall?.[1]
)
?.replace(/^(?:和|跟|与)\s*/, '')
.trim()
const topicQuery = (conversationTopic?.[2] || globalTopic?.[1])
?.replace(/^(?:关于|一下|吗|么)\s*/, '')
.trim()
const intent: AiSearchIntent = conversationTopic
? 'conversation_topic_search'
: recall || reverseRecall
? 'conversation_recall'
: namedConversationRecall
? 'conversation_name_search'
: globalTopic
? 'global_topic_search'
: conversationName
? 'conversation_name_search'
: keywords.length
? 'global_topic_search'
: 'general'
const effectiveKeywords = topicQuery ? [topicQuery] : keywords
return {
intent,
keywords: effectiveKeywords,
variants: keywordVariants(effectiveKeywords),
source: 'local',
contactQuery: contactQuery || conversationName,
topicQuery
}
}
export const parseAiSearchPlan = (
value: string
): Partial<Pick<AiSearchPlan, 'intent' | 'keywords' | 'variants' | 'topicQuery'>> | null => {
const jsonMatch = value.match(/\{[\s\S]*\}/)
if (!jsonMatch) return null
try {
const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>
const intent = [
'general',
'conversation_recall',
'conversation_topic_search',
'global_topic_search',
'conversation_name_search'
].includes(String(parsed.intent))
? (parsed.intent as AiSearchIntent)
: undefined
return {
intent,
keywords: normalizeTerms(parsed.keywords),
variants: normalizeTerms(parsed.variants),
topicQuery:
typeof parsed.topicQuery === 'string' && parsed.topicQuery.trim().length >= 2
? parsed.topicQuery.trim().slice(0, 64)
: undefined
}
} catch {
return null
}
}
export const mergeAiSearchPlans = (
local: Pick<
AiSearchPlan,
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery'
>,
ai: Partial<Pick<AiSearchPlan, 'intent' | 'keywords' | 'variants' | 'topicQuery'>> | null
): Pick<
AiSearchPlan,
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery'
> => {
if (!ai) return local
const keywords = normalizeTerms([...local.keywords, ...(ai.keywords || [])])
const variants = normalizeTerms([
...keywordVariants(keywords),
...local.variants,
...(ai.variants || [])
])
// Identity-bearing local intents are deterministic contracts. A planner may
// refine topic terms but may not weaken them into an unrelated FTS intent.
const lockedIntent =
local.intent === 'conversation_recall' ||
local.intent === 'conversation_topic_search' ||
local.intent === 'conversation_name_search'
return {
intent: lockedIntent ? local.intent : ai.intent || local.intent,
keywords,
variants,
source: 'hybrid',
contactQuery: local.contactQuery,
topicQuery: local.topicQuery || ai.topicQuery
}
}
export const includesExplicitAiSearchAlias = (query: string, alias: string): boolean => {
const name = alias.trim()
if (!name || name.length < 2) return false
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const quoted = new RegExp(`[“"'‘「『]${escaped}[”"'’」』]`)
const relational = new RegExp(
`(?:我和|我跟|我与|和|跟|与|在|给|向|@)${escaped}(?=$|[\\s,。!?!?、::;;])`
)
if (quoted.test(query) || relational.test(query)) return true
// Users often omit a nickname's punctuation, for example typing
// “中田健身弘毅” for “中田健身-弘毅”. Keep this tolerant matching limited
// to an explicit relational query so a short alias cannot accidentally
// select a contact from unrelated prose.
const compact = (value: string): string =>
value.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
const compactName = compact(name)
return (
compactName.length >= 2 &&
/(?:我和|我跟|我与|和|跟|与|在|给|向|@)/.test(query) &&
compact(query).includes(compactName)
)
}
+2 -2
View File
@@ -1,7 +1,7 @@
export type CacheClearScope = 'bootstrap' | 'electron' | 'all'
export type CacheClearScope = 'bootstrap' | 'electron' | 'knowledge' | 'all'
export interface CacheSummaryItem {
id: 'bootstrap' | 'electron'
id: 'bootstrap' | 'electron' | 'knowledge'
label: string
description: string
sizeBytes: number
+38
View File
@@ -0,0 +1,38 @@
export type ContactResolutionMatch = 'exact' | 'normalized' | 'alias' | 'fuzzy'
export interface ContactResolutionCandidate {
conversationId: string
displayName: string
matchedBy: ContactResolutionMatch
confidence: number
}
export interface ContactResolutionResult {
matched: boolean
personId?: string
conversationId?: string
canonicalName?: string
displayName?: string
matchedBy?: ContactResolutionMatch
confidence: number
candidates: ContactResolutionCandidate[]
ambiguous: boolean
}
/**
* Identity-only canonicalization. It deliberately does not use substring
* matching: callers may use a separate UI-filter policy for broad discovery,
* but identity resolution must never turn into .
*/
export const normalizeContactName = (value: string): string =>
String(value || '')
.normalize('NFKC')
.toLocaleLowerCase()
.replace(/[\p{White_Space}\p{P}\p{S}_]+/gu, '')
export const emptyContactResolution = (): ContactResolutionResult => ({
matched: false,
confidence: 0,
candidates: [],
ambiguous: false
})
+344
View File
@@ -0,0 +1,344 @@
/**
* Contracts for the local, derived knowledge base. These values deliberately
* contain no WCDB handles, Electron objects, database keys, or UI state so the
* indexer can run in an isolated process.
*/
export const KNOWLEDGE_SCHEMA_VERSION = 1
export const DEFAULT_CHUNKER_VERSION = 'conversation-v1'
export type KnowledgeMessageKind = 'text' | 'voice' | 'file' | 'link' | 'system' | 'other'
export type KnowledgeIndexPhase =
| 'idle'
| 'preflight'
| 'indexing'
| 'ready'
| 'cancelled'
| 'error'
export type KnowledgeTemporalIntent = 'none' | 'current' | 'historical' | 'timeline'
export type KnowledgeFtsTokenizer = 'unicode61' | 'trigram'
export type KnowledgeFtsContentMode = 'external' | 'internal'
export type KnowledgeFtsDetail = 'full' | 'column' | 'none'
export interface KnowledgeAttachmentMetadata {
name: string
kind?: 'file' | 'link' | 'image' | 'video' | 'other'
url?: string
sizeBytes?: number
}
/** A read-only source record prepared by the future WCDB adapter. */
export interface KnowledgeSourceMessage {
accountId: string
conversationId: string
messageId: string
/** Unix epoch milliseconds. Adapters must convert source-specific units. */
createTime: number
senderId?: string
senderName?: string
kind: KnowledgeMessageKind
text?: string
attachment?: KnowledgeAttachmentMetadata
voiceTranscript?: string
}
export interface KnowledgeNormalizedMessage extends KnowledgeSourceMessage {
searchableText: string
contentHash: string
}
export interface KnowledgeChunkerConfig {
version: string
maxGapMs: number
maxMessages: number
maxCharacters: number
overlapMessages: number
}
export interface KnowledgeChunk {
chunkId: string
accountId: string
conversationId: string
startTime: number
endTime: number
text: string
messageIds: string[]
participantIds: string[]
messageKinds: KnowledgeMessageKind[]
contentHash: string
chunkerVersion: string
}
/**
* Every FTS choice is explicit. The first production profile must be selected
* from the Task 0 report rather than being silently hard-coded in the UI.
*/
export interface KnowledgeFtsConfig {
profileId: string
tokenizer: KnowledgeFtsTokenizer
contentMode: KnowledgeFtsContentMode
detail: KnowledgeFtsDetail
columnsize: 0 | 1
}
/**
* Chosen after the realistic desensitized WeChat benchmark: trigram preserves
* Chinese-substring recall while external content avoids a second text copy.
*/
export const DEFAULT_KNOWLEDGE_FTS_CONFIG: KnowledgeFtsConfig = {
profileId: 'trigram-external-full-columnsize-v1',
tokenizer: 'trigram',
contentMode: 'external',
detail: 'full',
columnsize: 1
}
export interface KnowledgeConversationInput {
conversationId: string
/** true means this is a complete read-only snapshot of the conversation. */
completeSnapshot: boolean
messages: KnowledgeSourceMessage[]
}
export interface KnowledgeIndexRequest {
accountId: string
databaseRoot: string
conversations: KnowledgeConversationInput[]
chunker: KnowledgeChunkerConfig
fts: KnowledgeFtsConfig
/** Written only after a complete source pass; used for truthful coverage. */
sourceMessageCount?: number
}
export interface KnowledgeIndexProgress {
accountId: string
phase: KnowledgeIndexPhase
conversationId?: string
processedMessages: number
totalMessages: number
indexedChunks: number
error?: string
}
export interface KnowledgeIndexResult {
accountId: string
processedMessages: number
indexedChunks: number
updatedChunks: number
unchangedConversations: number
databaseBytes: number
walBytes: number
elapsedMs: number
cancelled: boolean
}
export interface KnowledgeCapacityPreflightRequest {
accountId: string
databaseRoot: string
conversations: KnowledgeConversationInput[]
chunker: KnowledgeChunkerConfig
/** Optional free space supplied by the platform layer; this module never probes WCDB paths. */
availableDiskBytes?: number
}
export interface KnowledgeCapacityPreflight {
accountId: string
sourceMessageCount: number
indexableMessageCount: number
indexableTextBytes: number
voiceTranscriptCount: number
attachmentMetadataCount: number
sampledChunkCount: number
estimatedChunkCount: number
estimatedDatabaseBytesLow: number
estimatedDatabaseBytesHigh: number
estimatedBuildPeakBytesLow: number
estimatedBuildPeakBytesHigh: number
availableDiskBytes?: number
hasSufficientDiskSpace?: boolean
warnings: string[]
}
export interface KnowledgeEvidence {
chunkId: string
conversationId: string
startTime: number
endTime: number
/** Stable source-message identity used by the archive jump action. */
messageId: string
senderId?: string
sender: string
/** Unix epoch milliseconds. */
timestamp: number
messageIds: string[]
text: string
score?: number
}
/** A bounded, local summary of a single conversation retrieval. */
export interface KnowledgeConversationRetrieval {
conversationId: string
totalMessages: number
chunkCount: number
candidateMessages: number
systemMessagesDeprioritized: number
complete: boolean
}
export interface KnowledgeQuery {
accountId: string
text: string
/** Query-router terms. The raw question remains available for diagnostics. */
terms?: string[]
limit: number
conversationId?: string
conversationIds?: string[]
senderIds?: string[]
/** Unix epoch milliseconds. */
startTime?: number
/** Unix epoch milliseconds. */
endTime?: number
temporalIntent?: KnowledgeTemporalIntent
}
export interface KnowledgeSearchRequest extends KnowledgeQuery {
databaseRoot: string
fts: KnowledgeFtsConfig
}
export type KnowledgeSearchState = 'unavailable' | 'indexing' | 'ready'
/** Measured in the Worker; never inferred from message counts or UI timers. */
export interface KnowledgeSearchTimings {
/** Parent/child-process transport and host scheduling outside SQLite work. */
workerIpcMs: number
/** First request only: child process spawn and Node initialization until it received the request. */
workerBootMs: number
/** Parent send → Worker handler start. */
dispatchMs: number
/** Worker local SQLite/chunk work; equals the Worker-side search total. */
workerSqlMs: number
/** Worker response preparation → parent receipt; includes IPC serialization/transfer. */
responseTransferMs: number
/** Worker-side serialization preflight for the result payload. */
responseSerializeMs: number
/** FTS (or short-term database lookup) query time. */
ftsMs: number
/** Reading source message rows from matching chunks. */
messageLoadMs: number
/** Expanding chunk members, scoring terms and per-chunk de-duplication. */
chunkExpandMs: number
/** Final result ordering and limit application. */
rankingMs: number
/** Worker-side local search total. */
totalMs: number
}
export const emptyKnowledgeSearchTimings = (): KnowledgeSearchTimings => ({
workerIpcMs: 0,
workerBootMs: 0,
dispatchMs: 0,
workerSqlMs: 0,
responseTransferMs: 0,
responseSerializeMs: 0,
ftsMs: 0,
messageLoadMs: 0,
chunkExpandMs: 0,
rankingMs: 0,
totalMs: 0
})
export interface KnowledgeSearchResult {
state: KnowledgeSearchState
evidence: KnowledgeEvidence[]
indexedMessageCount: number
indexedChunkCount: number
timings: KnowledgeSearchTimings
conversationRetrieval?: KnowledgeConversationRetrieval
}
/** Renderer-facing request. Chat timestamps use Unix seconds in the existing UI. */
export interface KnowledgeSearchIpcRequest {
text: string
terms: string[]
conversationIds?: string[]
senderIds?: string[]
startTime?: number
endTime?: number
limit?: number
}
export interface KnowledgeSearchIpcResult extends KnowledgeSearchResult {
source: 'knowledge' | 'fallback'
totalMessages: number
fallbackReason?: 'unavailable' | 'indexing' | 'error'
}
export type KnowledgeRuntimeState = 'unavailable' | 'building' | 'syncing' | 'ready' | 'error'
export interface KnowledgeRuntimeStatus {
accountId: string
state: KnowledgeRuntimeState
indexedMessageCount: number
indexedChunkCount: number
/** Null means this source pass has not yet counted every source message. */
sourceMessageCount: number | null
processedMessages: number
totalMessages: number | null
currentConversationId?: string
/** Null is displayed as unavailable rather than a fabricated ETA. */
estimatedRemainingMs: number | null
databaseBytes: number
walBytes: number
shmBytes: number
lastError?: string
}
export interface KnowledgeStatusRequest {
accountId: string
databaseRoot: string
fts: KnowledgeFtsConfig
}
export interface KnowledgeWorkerRequest {
version: 1
type: 'index' | 'preflight' | 'search' | 'status' | 'remove' | 'cancel' | 'close'
requestId: string
/** Parent monotonic wall-clock used only for transport timing. */
sentAt?: number
payload:
| KnowledgeIndexRequest
| KnowledgeCapacityPreflightRequest
| KnowledgeSearchRequest
| KnowledgeStatusRequest
| { accountId: string; databaseRoot: string }
| { targetRequestId: string }
| Record<string, never>
}
export interface KnowledgeWorkerResponse {
version: 1
type: 'progress' | 'result' | 'error'
requestId: string
payload?:
| KnowledgeIndexProgress
| KnowledgeIndexResult
| KnowledgeCapacityPreflight
| KnowledgeSearchResult
| KnowledgeRuntimeStatus
| { removed: true }
error?: string
transport?: {
workerReceivedAt: number
workerCompletedAt: number
responseSerializeMs: number
}
}
export const DEFAULT_KNOWLEDGE_CHUNKER: KnowledgeChunkerConfig = {
version: DEFAULT_CHUNKER_VERSION,
maxGapMs: 10 * 60 * 1000,
maxMessages: 12,
maxCharacters: 1200,
overlapMessages: 3
}