mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 20:19:09 +08:00
test: 暂存代码
This commit is contained in:
@@ -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