Files
WechatExplorer/src/renderer/src/components/search/searchUtils.ts
T

469 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Contact, Message } from '../../../../shared/types'
import { inferAiSearchTimeRange, type AiSearchTimeRange } from '../../../../shared/ai-search'
import type {
AISearchCacheRecord,
EvidenceItem,
GroupMemberName,
SearchIntent,
SearchQueryPlan,
SearchRange,
SearchScope
} from './searchTypes'
export const RANGE_LABELS: Record<SearchRange, string> = {
today: '今天',
'7d': '近 7 天',
'30d': '近 30 天',
all: '全部历史'
}
// Search intent/coverage semantics are program-owned; never replay results
// created before the current Evidence Collection contract.
export const SEARCH_CACHE_KEY = 'wxe_ai_search_cache_v12'
export const SEARCH_ACTIVE_RESULT_KEY = 'wxe_ai_search_active_result_v1'
export const SEARCH_HISTORY_KEY = 'wxe_ai_search_history_v1'
export const SEARCH_CACHE_LIMIT = 20
export const currentTimestamp = (): number => Date.now()
const SEARCH_INTENT_PHRASES = [
'全局搜一下',
'全局搜索',
'搜索一下',
'搜一下',
'查询一下',
'查一下',
'找一下',
'我和谁聊过',
'谁和我聊过',
'哪些人和我聊过',
'最近讨论了什么',
'最近聊了什么',
'最近说了什么',
'讨论了什么',
'讨论什么',
'聊了什么',
'聊些什么',
'说了什么',
'说些什么',
'最近讨论',
'最近聊天',
'这个话题',
'相关话题',
'的聊天',
'的内容',
'的记录',
'关于',
'聊天',
'记录',
'聊天记录',
'帮我',
'请问'
].sort((left, right) => right.length - left.length)
const SEARCH_STOP_WORDS = new Set([
'我',
'谁',
'什么',
'哪些',
'哪个',
'人',
'和',
'聊过',
'说过',
'提到',
'讨论',
'聊天',
'记录',
'说',
'聊',
'话题',
'内容',
'相关',
'最近',
'一下'
])
export const messageText = (message: Message): string =>
String(message.content || '').trim() || `[${message.type || '消息'}]`
export const normalizeSearchText = (value: string): string =>
value.toLowerCase().replace(/\s+/g, '')
export const includesSearchAlias = (query: string, alias: string): boolean => {
const normalizedAlias = normalizeSearchText(alias.trim())
return Boolean(normalizedAlias) && normalizeSearchText(query).includes(normalizedAlias)
}
const extractSearchKeywords = (query: string): string[] => {
const cleanedQuery = SEARCH_INTENT_PHRASES.reduce(
(value, phrase) => value.split(phrase).join(' '),
query.toLowerCase()
)
const tokens = cleanedQuery
.split(/[\s,,。!?!?、::;;"“”‘’()()[\]【】]+/)
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !SEARCH_STOP_WORDS.has(token))
return Array.from(new Set(tokens))
}
const getFuzzySearchKeywords = (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
})
)
)
const normalizeSearchTerms = (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)
}
export const buildLocalSearchPlan = (query: string): SearchQueryPlan => {
const keywords = extractSearchKeywords(query)
const asksParticipants = /我和谁|谁和我|哪些人|哪个人|哪些联系人/.test(query)
const intent: SearchIntent = asksParticipants
? keywords.length
? 'mixed'
: 'participants'
: keywords.length
? 'topic'
: 'general'
return {
intent,
keywords,
variants: getFuzzySearchKeywords(keywords),
source: 'local'
}
}
export const parseSearchPlanResponse = (value: string): Partial<SearchQueryPlan> | 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', 'topic', 'participants', 'mixed'].includes(String(parsed.intent))
? (parsed.intent as SearchIntent)
: undefined
return {
intent,
keywords: normalizeSearchTerms(parsed.keywords),
variants: normalizeSearchTerms(parsed.variants)
}
} catch {
return null
}
}
export const mergeSearchPlans = (
localPlan: SearchQueryPlan,
aiPlan: Partial<SearchQueryPlan> | null
): SearchQueryPlan => {
if (!aiPlan) return localPlan
const keywords = normalizeSearchTerms([...(localPlan.keywords || []), ...(aiPlan.keywords || [])])
const variants = normalizeSearchTerms([
...getFuzzySearchKeywords(keywords),
...(localPlan.variants || []),
...(aiPlan.variants || [])
])
return {
intent: aiPlan.intent || localPlan.intent,
keywords,
variants,
source: 'hybrid'
}
}
export const messageIdentity = (message: Message): string =>
message.localId
? `local:${message.localId}`
: message.id || `${message.createTime}:${message.content}`
export const evidenceIdentity = ({ contact, message }: EvidenceItem): string =>
`${contact.md5}:${messageIdentity(message)}`
export const formatMessageTime = (message: Message): string => {
if (message.datetime) return message.datetime
if (message.createTime) return new Date(message.createTime * 1000).toLocaleString('zh-CN')
return '未知时间'
}
export const getRangeStart = (range: SearchRange): 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)
}
const days = range === '7d' ? 7 : 30
return Math.floor(Date.now() / 1000) - days * 86400
}
export const messageDateKey = (message: Message): string => {
if (!message.createTime) return 'unknown'
const date = new Date(message.createTime * 1000)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export const formatMessageDate = (dateKey: string): string => {
if (dateKey === 'unknown') return '--/--'
const [, month, day] = dateKey.split('-')
return `${month}/${day}`
}
export const selectEvenly = <T>(items: T[], count: number): T[] => {
if (count >= items.length) return items
if (count <= 0) return []
if (count === 1) return [items[items.length - 1]]
return Array.from({ length: count }, (_item, index) => {
const itemIndex = Math.round((index * (items.length - 1)) / (count - 1))
return items[itemIndex]
})
}
export const selectEvidenceByDate = (items: EvidenceItem[], maxItems: number): EvidenceItem[] => {
const buckets = new Map<string, EvidenceItem[]>()
items.forEach((item) => {
const key = messageDateKey(item.message)
const bucket = buckets.get(key) || []
bucket.push(item)
buckets.set(key, bucket)
})
const entries = Array.from(buckets.entries()).sort(([left], [right]) => left.localeCompare(right))
const targetCount = Math.min(maxItems, items.length)
const counts = entries.map(() => 0)
let remaining = targetCount
while (remaining > 0) {
let bestIndex = -1
let bestRemaining = 0
entries.forEach(([, bucket], index) => {
const available = bucket.length - counts[index]
if (available > bestRemaining) {
bestIndex = index
bestRemaining = available
}
})
if (bestIndex < 0) break
counts[bestIndex] += 1
remaining -= 1
}
return entries
.flatMap((entry, index) => selectEvenly(entry[1], counts[index]))
.sort((left, right) => (left.message.createTime || 0) - (right.message.createTime || 0))
}
const looksLikeUserId = (value: string): boolean =>
value.startsWith('wxid_') || value.includes('@chatroom') || /^\d{6,}$/.test(value)
export const formatMemberName = (member: GroupMemberName): string =>
member.groupNickname || member.wechatNickname || member.nickname || member.remark || member.wxid
export const senderName = (
message: Message,
contact: Contact,
names: Record<string, string>
): string => {
const identifiers = [message.senderId, message.from, message.name].filter(
(value): value is string => Boolean(value?.trim())
)
const mappedName = identifiers.map((value) => names[value]).find(Boolean)
if (mappedName) return mappedName
if (message.name && !looksLikeUserId(message.name)) return message.name
if (message.isSender) return '我'
return contact.type === 'user' ? contact.m_nsNickName || '联系人' : '群成员'
}
export const compactCacheItem = ({ evidenceId, contact, message }: EvidenceItem): EvidenceItem => ({
evidenceId,
contact: {
md5: contact.md5,
m_nsUsrName: contact.m_nsUsrName,
m_nsNickName: contact.m_nsNickName,
type: contact.type,
avatar: contact.avatar
},
message: {
id: message.id,
from: message.from,
type: message.type,
datetime: message.datetime,
content: message.content,
isSender: message.isSender,
name: message.name,
senderId: message.senderId,
localId: message.localId,
serverId: message.serverId,
createTime: message.createTime
}
})
export const buildSearchCacheKey = (
scope: SearchScope,
contactMd5: string,
range: SearchRange,
query: string,
resolvedTimeRange?: Pick<AiSearchTimeRange, 'startTime' | 'endTime'>,
knowledgeGeneration?: string
): string => {
const base = [scope, contactMd5, range, query.trim().toLowerCase()]
const hasResolvedTime = Boolean(
resolvedTimeRange &&
(resolvedTimeRange.startTime !== undefined || resolvedTimeRange.endTime !== undefined)
)
if (!hasResolvedTime && !knowledgeGeneration) return JSON.stringify(base)
return JSON.stringify([
...base,
hasResolvedTime ? resolvedTimeRange?.startTime ?? null : null,
hasResolvedTime ? resolvedTimeRange?.endTime ?? null : null,
knowledgeGeneration || null
])
}
export type CreateSearchRequestContextInput = {
query: string
scope: SearchScope
range: SearchRange
timeRangeOverride?: AiSearchTimeRange
activeContactMd5?: string
now?: Date
knowledgeGeneration?: string
retry?: {
range: SearchRange
timeRangeOverride?: AiSearchTimeRange
}
}
export type SearchRequestContext = {
normalizedQuery: string
effectiveRange: SearchRange
effectiveTimeRangeOverride?: AiSearchTimeRange
conversationId?: string
resolvedTimeRange: AiSearchTimeRange
cacheKey: string
}
export const createSearchRequestContext = ({
query,
scope,
range,
timeRangeOverride,
activeContactMd5,
now,
knowledgeGeneration,
retry
}: CreateSearchRequestContextInput): SearchRequestContext => {
const normalizedQuery = query.trim()
const effectiveRange = retry?.range || range
const effectiveTimeRangeOverride = retry?.timeRangeOverride || timeRangeOverride
const conversationId = scope === 'conversation' ? activeContactMd5 : undefined
const resolvedTimeRange = inferAiSearchTimeRange(
normalizedQuery,
effectiveRange,
now || new Date(),
effectiveTimeRangeOverride
)
return {
normalizedQuery,
effectiveRange,
effectiveTimeRangeOverride,
conversationId,
resolvedTimeRange,
cacheKey: buildSearchCacheKey(
scope,
conversationId || '',
effectiveRange,
normalizedQuery,
resolvedTimeRange,
knowledgeGeneration
)
}
}
export const parseSearchCacheKey = (
key: string
): { scope: SearchScope; contactMd5: string; range: SearchRange; query: string; startTime?: number; endTime?: number } | null => {
try {
const parts = JSON.parse(key) as unknown
if (
!Array.isArray(parts) ||
!['global', 'conversation'].includes(String(parts[0])) ||
!['today', '7d', '30d', 'all'].includes(String(parts[2])) ||
typeof parts[3] !== 'string'
) {
return null
}
return {
scope: parts[0] as SearchScope,
contactMd5: typeof parts[1] === 'string' ? parts[1] : '',
range: parts[2] as SearchRange,
query: parts[3],
startTime: typeof parts[4] === 'number' ? parts[4] : undefined,
endTime: typeof parts[5] === 'number' ? parts[5] : undefined
}
} catch {
return null
}
}
export const readSearchCache = (key: string): AISearchCacheRecord | null => {
try {
const records = JSON.parse(
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
) as AISearchCacheRecord[]
const record = records.find((item) => item.version === 3 && item.key === key)
return record || null
} catch {
return null
}
}
export const readSearchCacheByQuery = (
query: string
): { record: AISearchCacheRecord; location: ReturnType<typeof parseSearchCacheKey> } | null => {
try {
const records = JSON.parse(
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
) as AISearchCacheRecord[]
const normalizedQuery = query.trim().toLowerCase()
for (const record of records) {
const location = parseSearchCacheKey(record.key)
if (record.version === 3 && location?.query === normalizedQuery) {
return { record, location }
}
}
return null
} catch {
return null
}
}
export const writeSearchCache = (record: AISearchCacheRecord): void => {
try {
const records = JSON.parse(
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
) as AISearchCacheRecord[]
const nextRecords = [record, ...records.filter((item) => item.key !== record.key)].slice(
0,
SEARCH_CACHE_LIMIT
)
localStorage.setItem(SEARCH_CACHE_KEY, JSON.stringify(nextRecords))
} catch {
// A large message result must not break the search itself.
}
}