mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
test: 暂存代码
This commit is contained in:
+46
-3
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 0~Task 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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
@@ -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: '已达到本次检索上限' }
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+17
-1
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user