import { aiSearchIntentLabel, aiSearchScopeLabel, buildLocalAiSearchPlan, inferAiSearchTimeRange, mergeAiSearchPlans, parseAiSearchPlan, type AiSearchAgentRun, type AiSearchAgentTraceItem, type AiSearchAggregation, type AiSearchFinalEvidence, type AiSearchPipelineEvidence, type AiSearchPipelineRequest, type AiSearchPipelineResult, type AiSearchPipelineTimings, type AiSearchPlan, type AiSearchRetrievalContract, type AiSearchProgressEvent } from '../../shared/ai-search' import { emptyKnowledgeSearchTimings, type KnowledgeSearchIpcResult } from '../../shared/knowledge' import type { Contact } from '../../shared/types' import * as chat from './chat-service' import { buildFinalEvidence, sanitizeAnswerCitations } from './ai-search-evidence' import { runControlledSearchAgent, type AgentAction, type AgentToolResult } from './ai-search-agent' import { AIProviderService } from './ai-provider-service' import { KnowledgeSearchService } from '../knowledge/knowledge-search-service' import { resolveContact, type ContactResolutionScope } from './contact-resolution-service' const DISPLAY_EVIDENCE_LIMIT = 8 const AGENT_MESSAGE_LIMIT = 100 const AGENT_SEARCH_LIMIT = 50 const MAX_AGENT_CANDIDATES = 240 type AgentSearchOutcome = { invalid?: boolean candidateEvidence: AiSearchPipelineEvidence[] searchResult: KnowledgeSearchIpcResult plan: AiSearchPlan agent: AiSearchAgentRun searchTimings: ReturnType knowledgeSearchMs: number } const contactScopeForIntent = (intent: AiSearchPlan['intent']): ContactResolutionScope => intent === 'conversation_name_search' ? 'group' : 'person' const isIdentityIntent = (intent: AiSearchPlan['intent']): boolean => intent === 'conversation_recall' || intent === 'conversation_topic_search' || intent === 'conversation_name_search' const retrievalModeForIntent = ( intent: AiSearchPlan['intent'] ): AiSearchRetrievalContract['retrievalMode'] => intent === 'conversation_recall' ? 'conversation_metadata' : intent === 'conversation_topic_search' ? 'conversation_topic_fts' : intent === 'conversation_name_search' ? 'conversation_name' : intent === 'global_topic_search' ? 'global_fts' : 'global_fts' const contactLabel = (contact: Contact | undefined): string => contact?.m_nsNickName || contact?.remark || contact?.wechatNickname || contact?.m_nsUsrName || '当前会话' const messageTime = (timestamp: number): string => new Date(timestamp).toLocaleString('zh-CN', { hour12: false }) const estimateTokens = (value: string): number => Math.ceil(value.length / 2) const emptyAggregation = (): AiSearchAggregation => ({ messageCount: 0, peopleCount: 0, conversationCount: 0, people: [], conversations: [] }) const emptyTimings = (): AiSearchPipelineTimings => ({ queryUnderstandingMs: 0, contactResolutionMs: 0, knowledgeSearchMs: 0, workerIpcMs: 0, workerBootMs: 0, dispatchMs: 0, workerSqlMs: 0, responseSerializeMs: 0, responseTransferMs: 0, ftsMs: 0, chunkExpandMs: 0, messageLoadMs: 0, rankingMs: 0, candidateRankingMs: 0, evidenceBuildMs: 0, aggregationMs: 0, contextPreparationMs: 0, agentDecisionMs: 0, agentToolMs: 0, aiGenerationMs: 0, totalMs: 0 }) /** * Main-process search orchestrator. It owns the only transition from raw * candidates to Final Evidence; the AI and Renderer never receive a wider * candidate context than the final program-generated citations. */ export class AiSearchPipelineService { constructor( private readonly knowledge: KnowledgeSearchService, private readonly aiProvider: AIProviderService ) {} async run( request: AiSearchPipelineRequest, publish: (event: AiSearchProgressEvent) => void ): Promise { const startedAt = Date.now() const timings = emptyTimings() let activeStage: AiSearchProgressEvent['stage'] = 'query_understanding' const initialTimeRange = inferAiSearchTimeRange( request.text, request.range, new Date(), request.timeRangeOverride ) let plan: AiSearchPlan = { ...buildLocalAiSearchPlan(request.text), scopeLabel: aiSearchScopeLabel(request.scope), timeRange: initialTimeRange, rangeLabel: initialTimeRange.label, contactNames: [] } const snapshotTimings = (): AiSearchPipelineTimings => ({ ...timings, totalMs: Date.now() - startedAt }) const emit = (event: Omit): void => publish({ requestId: request.requestId, ...event }) try { emit({ stage: 'query_understanding', status: 'running', message: '正在理解你的问题' }) const queryUnderstandingStartedAt = Date.now() const aiConfig = this.aiProvider.getRuntimeConfig() const contactResolutionStartedAt = Date.now() const contacts = chat.isReady() ? await chat.listContactsAsync() : [] const selectedContact = request.conversationId ? contacts.find((contact) => contact.md5 === request.conversationId) : undefined const sourceContacts = this.scopeContacts(contacts, request, selectedContact) if (!sourceContacts.length) throw new Error('当前搜索范围没有可用会话') const contactResolution = plan.contactQuery ? resolveContact(plan.contactQuery, sourceContacts, contactScopeForIntent(plan.intent)) : undefined const resolvedContact = contactResolution?.matched ? sourceContacts.find((contact) => contact.md5 === contactResolution.conversationId) : selectedContact plan = { ...plan, scopeLabel: aiSearchScopeLabel(request.scope, contactLabel(selectedContact)), contactNames: resolvedContact ? [contactLabel(resolvedContact)] : [] } const conversationIds = isIdentityIntent(plan.intent) && resolvedContact ? [resolvedContact.md5] : request.scope === 'global' ? undefined : sourceContacts.map((contact) => contact.md5) timings.contactResolutionMs = Date.now() - contactResolutionStartedAt let agent: AiSearchAgentRun = { mode: 'fallback', toolCalls: 0, trace: [] } let candidateEvidence: AiSearchPipelineEvidence[] let searchResult: KnowledgeSearchIpcResult const agentOutcome = aiConfig.configured ? await this.runAgentSearch( request, plan, contacts, sourceContacts, selectedContact, resolvedContact, (trace) => { agent.trace.push(trace) if (trace.event === 'agentDecision') timings.agentDecisionMs += trace.elapsedMs || 0 if (trace.event === 'toolCallEnd') timings.agentToolMs += trace.elapsedMs || 0 emit({ stage: trace.event === 'agentStart' ? 'agent_start' : trace.event === 'agentDecision' ? 'agent_decision' : 'agent_tool', status: 'completed', message: trace.label, plan, agentTrace: trace, timings: snapshotTimings() }) } ) : null if (agentOutcome && !agentOutcome.invalid) { plan = agentOutcome.plan agent = agentOutcome.agent candidateEvidence = agentOutcome.candidateEvidence searchResult = agentOutcome.searchResult timings.knowledgeSearchMs += agentOutcome.knowledgeSearchMs timings.workerIpcMs += agentOutcome.searchTimings.workerIpcMs timings.workerBootMs += agentOutcome.searchTimings.workerBootMs timings.dispatchMs += agentOutcome.searchTimings.dispatchMs timings.workerSqlMs += agentOutcome.searchTimings.workerSqlMs timings.responseSerializeMs += agentOutcome.searchTimings.responseSerializeMs timings.responseTransferMs += agentOutcome.searchTimings.responseTransferMs timings.ftsMs += agentOutcome.searchTimings.ftsMs timings.chunkExpandMs += agentOutcome.searchTimings.chunkExpandMs timings.messageLoadMs += agentOutcome.searchTimings.messageLoadMs timings.rankingMs += agentOutcome.searchTimings.rankingMs } else { const deterministicIdentityRetrieval = isIdentityIntent(plan.intent) && Boolean(resolvedContact) const unresolvedIdentity = isIdentityIntent(plan.intent) && !resolvedContact const fallbackReason = deterministicIdentityRetrieval ? '受控搜索 Agent 未返回有效控制指令,已按相同检索意图的本地确定性策略继续' : unresolvedIdentity ? '未能唯一确认目标联系人或群聊,未执行消息关键词搜索' : aiConfig.configured ? '受控搜索 Agent 暂时不可用,已改用原有检索方式' : '尚未配置可用 AI 模型,已改用原有检索方式' agent = { mode: 'fallback', toolCalls: agentOutcome?.agent.toolCalls || 0, fallbackReason, trace: [ ...(agentOutcome?.agent.trace || []), { sequence: (agentOutcome?.agent.trace.length || 0) + 1, event: 'fallback', label: fallbackReason } ] } emit({ stage: 'agent_start', status: 'completed', message: fallbackReason, plan, agentTrace: agent.trace[0], timings: snapshotTimings() }) if (aiConfig.configured && !deterministicIdentityRetrieval && !unresolvedIdentity) { const planningStartedAt = Date.now() const planning = await this.aiProvider.chat([ { role: 'system', content: '你是本地聊天检索规划器,不回答用户问题。请从用户问题中提取用于本地数据库检索的主题词和同义短语,只输出 JSON:{"intent":"global_topic_search|general","keywords":["..."],"variants":["..."],"topicQuery":"..."}。不要编造人名或聊天内容;联系人身份和会话回顾由程序决定。' }, { role: 'user', content: `用户问题:${request.text}` } ]) timings.queryUnderstandingMs += Date.now() - planningStartedAt if (planning.success && planning.data) { plan = { ...mergeAiSearchPlans( buildLocalAiSearchPlan(request.text), parseAiSearchPlan(planning.data) ), scopeLabel: plan.scopeLabel, rangeLabel: plan.rangeLabel, timeRange: plan.timeRange, contactNames: resolvedContact ? [contactLabel(resolvedContact)] : [] } } } activeStage = 'knowledge_searching' emit({ stage: 'knowledge_searching', status: 'running', message: '正在本地知识库中查找', plan, timings: snapshotTimings() }) if (unresolvedIdentity) { searchResult = { source: 'knowledge', state: 'ready', indexedMessageCount: 0, indexedChunkCount: 0, totalMessages: 0, evidence: [], timings: emptyKnowledgeSearchTimings() } candidateEvidence = [] } else { const knowledgeSearchStartedAt = Date.now() const deterministicTerms = plan.intent === 'conversation_recall' || plan.intent === 'conversation_name_search' ? [] : plan.intent === 'conversation_topic_search' ? plan.topicQuery ? [plan.topicQuery] : [] : Array.from(new Set([...plan.keywords, ...plan.variants])) searchResult = await this.knowledge.search({ text: request.text, terms: deterministicTerms, conversationIds, startTime: plan.timeRange.startTime, endTime: plan.timeRange.endTime, limit: 240 }) timings.knowledgeSearchMs += Date.now() - knowledgeSearchStartedAt candidateEvidence = this.toPipelineEvidence(searchResult, contacts) } } timings.queryUnderstandingMs += Date.now() - queryUnderstandingStartedAt - timings.contactResolutionMs - timings.agentDecisionMs - timings.agentToolMs timings.queryUnderstandingMs = Math.max(0, timings.queryUnderstandingMs) emit({ stage: 'query_understanding', status: 'completed', message: '已理解搜索条件', plan, timings: snapshotTimings() }) activeStage = 'search_plan_ready' emit({ stage: 'search_plan_ready', status: 'completed', message: `将${aiSearchIntentLabel(plan.intent)}`, plan, timings: snapshotTimings() }) const knowledgeTimings = searchResult.timings || emptyKnowledgeSearchTimings() if (!agentOutcome) { timings.workerIpcMs = knowledgeTimings.workerIpcMs timings.workerBootMs = knowledgeTimings.workerBootMs timings.dispatchMs = knowledgeTimings.dispatchMs timings.workerSqlMs = knowledgeTimings.workerSqlMs timings.responseSerializeMs = knowledgeTimings.responseSerializeMs timings.responseTransferMs = knowledgeTimings.responseTransferMs timings.ftsMs = knowledgeTimings.ftsMs timings.chunkExpandMs = knowledgeTimings.chunkExpandMs timings.messageLoadMs = knowledgeTimings.messageLoadMs timings.rankingMs = knowledgeTimings.rankingMs } const usedKnowledge = searchResult.source === 'knowledge' const knowledgeMessageCount = usedKnowledge ? searchResult.indexedMessageCount : undefined const searchMessage = usedKnowledge ? candidateEvidence.length ? `找到 ${candidateEvidence.length} 条相关消息` : '没有找到相关消息' : searchResult.fallbackReason === 'error' ? '本地知识库暂时不可用,已改用聊天记录查找' : '本地知识库尚未就绪,已改用聊天记录查找' emit({ stage: 'knowledge_searching', status: 'completed', message: searchMessage, plan, stats: { knowledgeMessageCount, matchedMessages: candidateEvidence.length, elapsedMs: timings.knowledgeSearchMs }, timings: snapshotTimings() }) let retrieval = this.buildRetrievalContract( plan, resolvedContact, searchResult, candidateEvidence, agent ) if (retrieval.suspicious && resolvedContact) { // An identity route that somehow yielded 0/1 records is never allowed // to masquerade as a complete chat recap. Retry the safe metadata read. const retryStartedAt = Date.now() searchResult = await this.knowledge.search({ text: request.text, terms: [], conversationIds: [resolvedContact.md5], startTime: plan.timeRange.startTime, endTime: plan.timeRange.endTime, limit: 240 }) timings.knowledgeSearchMs += Date.now() - retryStartedAt candidateEvidence = this.toPipelineEvidence(searchResult, contacts) retrieval = this.buildRetrievalContract( plan, resolvedContact, searchResult, candidateEvidence, agent ) } activeStage = 'evidence_ranking' emit({ stage: 'evidence_ranking', status: 'running', message: '正在整理最相关的原始消息', plan, stats: { knowledgeMessageCount, matchedMessages: candidateEvidence.length }, timings: snapshotTimings() }) const evidenceBuild = buildFinalEvidence(candidateEvidence, DISPLAY_EVIDENCE_LIMIT, { strategy: plan.intent === 'conversation_recall' ? 'conversation_coverage' : 'ranked' }) const evidence = evidenceBuild.evidence timings.candidateRankingMs = evidenceBuild.candidateRankingMs timings.evidenceBuildMs = evidenceBuild.evidenceBuildMs timings.aggregationMs = evidenceBuild.aggregationMs const evidenceTrace: AiSearchAgentTraceItem = { sequence: agent.trace.length + 1, event: 'evidenceBuild', label: '已从候选消息整理可引用证据', resultCount: evidence.length, elapsedMs: evidenceBuild.candidateRankingMs + evidenceBuild.evidenceBuildMs + evidenceBuild.aggregationMs } agent.trace.push(evidenceTrace) emit({ stage: 'evidence_ranking', status: 'completed', message: `已从 ${evidenceBuild.candidateCount} 条候选消息整理出 ${evidence.length} 条可引用证据`, plan, stats: { knowledgeMessageCount, matchedMessages: evidenceBuild.candidateCount, evidenceCount: evidence.length, contextEvidenceCount: evidence.length, deduplicatedMessages: evidenceBuild.deduplicatedCount }, agentTrace: evidenceTrace, timings: snapshotTimings() }) activeStage = 'evidence_ready' emit({ stage: 'evidence_ready', status: 'completed', message: evidence.length ? '已保留可跳转的原始消息引用' : '没有可引用的原始消息', plan, stats: { knowledgeMessageCount, matchedMessages: evidenceBuild.candidateCount, evidenceCount: evidence.length, contextEvidenceCount: evidence.length, deduplicatedMessages: evidenceBuild.deduplicatedCount }, timings: snapshotTimings() }) activeStage = 'aggregation' emit({ stage: 'aggregation', status: 'running', message: '正在按人物和会话整理证据', plan, stats: { evidenceCount: evidence.length }, timings: snapshotTimings() }) emit({ stage: 'aggregation', status: 'completed', message: `已整理 ${evidenceBuild.aggregation.peopleCount} 人、${evidenceBuild.aggregation.conversationCount} 个会话`, plan, stats: { evidenceCount: evidence.length, peopleCount: evidenceBuild.aggregation.peopleCount, conversationCount: evidenceBuild.aggregation.conversationCount }, timings: snapshotTimings() }) const baseResult = { requestId: request.requestId, plan, agent, knowledge: { source: searchResult.source, state: searchResult.state, fallbackReason: searchResult.fallbackReason, indexedMessageCount: searchResult.indexedMessageCount, indexedChunkCount: searchResult.indexedChunkCount, totalMessages: searchResult.totalMessages }, candidateEvidenceCount: evidenceBuild.candidateCount, retrieval, evidence, contextEvidenceCount: evidence.length, aggregation: evidenceBuild.aggregation, timings: snapshotTimings(), elapsedMs: Date.now() - startedAt } if (!evidence.length) { emit({ stage: 'completed', status: 'completed', message: '搜索完成,当前条件下没有找到相关消息', plan, stats: { knowledgeMessageCount, matchedMessages: 0, evidenceCount: 0 }, timings: snapshotTimings() }) return { ...baseResult, status: 'no_evidence', timings: snapshotTimings(), elapsedMs: Date.now() - startedAt } } if (retrieval.suspicious) { const error = '已找到目标会话,但当前检索未完整覆盖聊天记录,未生成总结。' emit({ stage: 'completed', status: 'completed', message: error, plan, stats: { knowledgeMessageCount, matchedMessages: evidenceBuild.candidateCount, evidenceCount: evidence.length }, timings: snapshotTimings(), error }) return { ...baseResult, status: 'retrieval_incomplete', error, timings: snapshotTimings(), elapsedMs: Date.now() - startedAt } } activeStage = 'ai_generating' const summaryTrace: AiSearchAgentTraceItem = { sequence: agent.trace.length + 1, event: 'summaryStart', label: '开始生成带来源的回答' } agent.trace.push(summaryTrace) const contextPreparationStartedAt = Date.now() const prompt = this.answerPrompt( request.text, plan, searchResult.totalMessages, evidence, evidenceBuild.aggregation, retrieval ) const tokenEstimate = estimateTokens(prompt) timings.contextPreparationMs = Date.now() - contextPreparationStartedAt if (!aiConfig.configured) { const error = '尚未配置可用 AI 模型' emit({ stage: 'ai_generating', status: 'error', message: '证据已找到,但无法生成回答', plan, stats: { matchedMessages: evidenceBuild.candidateCount, evidenceCount: evidence.length, contextEvidenceCount: evidence.length, tokenEstimate }, timings: snapshotTimings(), error }) return { ...baseResult, status: 'ai_failed', error, errorStage: 'ai_generating', timings: snapshotTimings(), elapsedMs: Date.now() - startedAt } } emit({ stage: 'ai_generating', status: 'running', message: '正在生成带来源的回答', plan, modelName: aiConfig.modelName, agentTrace: summaryTrace, stats: { matchedMessages: evidenceBuild.candidateCount, evidenceCount: evidence.length, contextEvidenceCount: evidence.length, tokenEstimate }, timings: snapshotTimings() }) const aiGenerationStartedAt = Date.now() const answer = await this.aiProvider.chat([ { role: 'system', content: '你是 WechatExplorer 的本地聊天记录分析助手。只能基于提供的程序化事实和 Evidence 回答,不得编造事实。请用中文回答,先给出简短摘要,再列出关键主题、结论和不确定性。引用关键事实时,只能使用 Evidence 原文中存在的 [E#],不要创建、猜测或改写 Evidence ID。对人物问题只能描述聊天中的发言主题和可能角色,不做人格或敏感属性判断。' }, { role: 'user', content: prompt } ]) timings.aiGenerationMs = Date.now() - aiGenerationStartedAt if (!answer.success || !answer.data) { const error = answer.error || 'AI 没有返回可用回答' emit({ stage: 'ai_generating', status: 'error', message: '证据已找到,但 AI 暂时无法生成回答', plan, modelName: aiConfig.modelName, stats: { matchedMessages: evidenceBuild.candidateCount, evidenceCount: evidence.length, contextEvidenceCount: evidence.length, tokenEstimate }, timings: snapshotTimings(), error }) return { ...baseResult, status: 'ai_failed', error, errorStage: 'ai_generating', timings: snapshotTimings(), elapsedMs: Date.now() - startedAt } } const citationValidation = sanitizeAnswerCitations(answer.data, evidence) const summaryEndTrace: AiSearchAgentTraceItem = { sequence: agent.trace.length + 1, event: 'summaryEnd', label: '已生成带来源的回答', elapsedMs: timings.aiGenerationMs } agent.trace.push(summaryEndTrace) const inputTokens = answer.usage?.input || tokenEstimate const inputTokensEstimated = !answer.usage?.input || Boolean(answer.usage?.estimated) const ai = { providerName: aiConfig.providerName, modelName: aiConfig.modelName, inputTokens, inputTokensEstimated } const completedMessage = citationValidation.invalidCitationIds.length ? '已生成回答;已移除无法对应原始消息的引用' : '已生成带来源的回答' emit({ stage: 'ai_generating', status: 'completed', message: completedMessage, plan, modelName: aiConfig.modelName, stats: { knowledgeMessageCount, matchedMessages: evidenceBuild.candidateCount, evidenceCount: evidence.length, contextEvidenceCount: evidence.length, inputTokens, inputTokensEstimated, peopleCount: evidenceBuild.aggregation.peopleCount, conversationCount: evidenceBuild.aggregation.conversationCount, elapsedMs: timings.aiGenerationMs }, timings: snapshotTimings(), agentTrace: summaryEndTrace }) emit({ stage: 'completed', status: 'completed', message: '已完成', plan, stats: { knowledgeMessageCount, matchedMessages: evidenceBuild.candidateCount, evidenceCount: evidence.length, contextEvidenceCount: evidence.length, inputTokens, inputTokensEstimated, peopleCount: evidenceBuild.aggregation.peopleCount, conversationCount: evidenceBuild.aggregation.conversationCount, elapsedMs: Date.now() - startedAt }, timings: snapshotTimings(), modelName: aiConfig.modelName }) return { ...baseResult, status: 'completed', answer: citationValidation.answer, ai, citationValidation: { status: citationValidation.status, invalidCitationIds: citationValidation.invalidCitationIds }, timings: snapshotTimings(), elapsedMs: Date.now() - startedAt } } catch (caught) { const error = caught instanceof Error ? caught.message : '搜索过程发生未知错误' emit({ stage: 'error', status: 'error', message: this.errorMessage(activeStage), plan, timings: snapshotTimings(), error }) return { requestId: request.requestId, status: 'failed', plan, knowledge: { source: 'fallback', state: 'unavailable', indexedMessageCount: 0, indexedChunkCount: 0, totalMessages: 0 }, candidateEvidenceCount: 0, evidence: [], contextEvidenceCount: 0, retrieval: { intent: plan.intent, timeRange: plan.timeRange, retrievalMode: 'global_fts', candidateCount: 0, sourceCoverage: 'unknown', isComplete: false, fallbackUsed: true, suspicious: false }, aggregation: emptyAggregation(), agent: { mode: 'fallback', toolCalls: 0, trace: [] }, timings: snapshotTimings(), error, errorStage: activeStage, elapsedMs: Date.now() - startedAt } } } private scopeContacts( contacts: Contact[], request: AiSearchPipelineRequest, selectedContact: Contact | undefined ): Contact[] { if (request.scope === 'groups') return contacts.filter((contact) => contact.type === 'group') if (request.scope === 'contacts') return contacts.filter((contact) => contact.type !== 'group') if (request.scope === 'conversation') return selectedContact ? [selectedContact] : [] return contacts } private toPipelineEvidence( result: KnowledgeSearchIpcResult, contacts: Contact[] ): AiSearchPipelineEvidence[] { const contactsById = new Map(contacts.map((contact) => [contact.md5, contact])) return result.evidence.map((item): AiSearchPipelineEvidence => { const contact = contactsById.get(item.conversationId) return { ...item, conversationName: contactLabel(contact), conversationType: contact?.type || (item.conversationId.endsWith('@chatroom') ? 'group' : 'user') } }) } /** * The model never receives source IDs. It only receives per-request refs * created from Tool results; every ref is checked again before a read. */ private async runAgentSearch( request: AiSearchPipelineRequest, initialPlan: AiSearchPlan, contacts: Contact[], sourceContacts: Contact[], selectedContact: Contact | undefined, resolvedContact: Contact | undefined, onTrace: (item: AiSearchAgentTraceItem) => void ): Promise { const contactsInScope = new Map(sourceContacts.map((contact) => [contact.md5, contact])) const conversationRefs = new Map() const refsByConversation = new Map() const messageRefs = new Map() const candidates: AiSearchPipelineEvidence[] = [] const trace: AiSearchAgentTraceItem[] = [] let traceSequence = 0 let lastSearchResult: KnowledgeSearchIpcResult = { source: 'knowledge', state: 'unavailable', indexedMessageCount: 0, indexedChunkCount: 0, totalMessages: 0, evidence: [], timings: emptyKnowledgeSearchTimings() } let plan = initialPlan const searchTimings = emptyKnowledgeSearchTimings() let knowledgeSearchMs = 0 const recordTrace = (item: Omit): void => { const next = { sequence: ++traceSequence, ...item } trace.push(next) onTrace(next) } const addConversationRef = (contact: Contact): string => { const existing = refsByConversation.get(contact.md5) if (existing) return existing const ref = `conversation-${conversationRefs.size + 1}` refsByConversation.set(contact.md5, ref) conversationRefs.set(ref, contact) return ref } if (selectedContact && contactsInScope.has(selectedContact.md5)) addConversationRef(selectedContact) if (resolvedContact && contactsInScope.has(resolvedContact.md5)) addConversationRef(resolvedContact) const boundedQuery = (value: unknown): string => { if (typeof value !== 'string') throw new Error('查询内容无效') const query = value.trim() if (query.length < 2 || query.length > 64) throw new Error('查询内容长度不符合限制') return query } const boundedLimit = (value: unknown, fallback: number, maximum: number): number => { if (value === undefined) return fallback if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > maximum) { throw new Error('读取数量不符合限制') } return value } const resolveConversation = (value: unknown): Contact => { if (typeof value !== 'string') throw new Error('必须先通过会话搜索取得目标') const contact = conversationRefs.get(value) if (!contact || !contactsInScope.has(contact.md5)) throw new Error('目标会话不在本次允许范围内') return contact } const matchingContacts = (query: string, peopleOnly: boolean): Contact[] => { const result = resolveContact(query, sourceContacts, peopleOnly ? 'person' : 'group') if (!result.matched || !result.conversationId || result.ambiguous) return [] const contact = sourceContacts.find((item) => item.md5 === result.conversationId) return contact ? [contact] : [] } const rejectForbiddenAction = (action: Extract): void => { const contactBound = Boolean(resolvedContact || selectedContact) const requiresConversationRef = action.tool === 'get_conversation_messages' || action.tool === 'get_message_context' || (action.tool === 'search_messages' && plan.intent === 'conversation_topic_search') if (requiresConversationRef && typeof action.arguments.conversationRef !== 'string') { throw new Error('当前检索意图要求先确认目标会话') } if (plan.intent === 'conversation_recall') { if (action.tool !== 'search_people' && action.tool !== 'get_conversation_messages') { throw new Error('联系人回顾只允许定位联系人后读取该会话消息') } if (action.tool === 'search_people' && contactBound && plan.contactQuery) { const actionResolution = resolveContact( action.arguments.query as string, sourceContacts, 'person' ) if ( !actionResolution.matched || actionResolution.conversationId !== resolvedContact?.md5 ) { throw new Error('联系人回顾只能使用已解析的目标联系人') } } } if (plan.intent === 'conversation_topic_search') { if (action.tool !== 'search_people' && action.tool !== 'search_messages') { throw new Error('联系人话题查询只允许定位联系人后在该会话内查找话题') } if ( action.tool === 'search_messages' && typeof action.arguments.conversationRef !== 'string' ) { throw new Error('联系人话题查询不能执行全局消息搜索') } } if (plan.intent === 'global_topic_search' && action.tool !== 'search_messages') { throw new Error('全局话题查询只允许查找消息内容') } if (plan.intent === 'conversation_name_search') { if (action.tool !== 'search_conversations' && action.tool !== 'get_conversation_messages') { throw new Error('聊天名称查询只允许定位聊天后读取该会话消息') } } } const addSearchCandidates = (result: KnowledgeSearchIpcResult): AiSearchPipelineEvidence[] => { lastSearchResult = result const evidence = this.toPipelineEvidence(result, contacts) evidence.forEach((item) => { if (candidates.length < MAX_AGENT_CANDIDATES) candidates.push(item) const key = `${item.conversationId}\u0000${item.messageId}` if ( !Array.from(messageRefs.values()).some( (value) => `${value.conversationId}\u0000${value.messageId}` === key ) ) { messageRefs.set(`message-${messageRefs.size + 1}`, item) } }) return evidence } const summarizeMessages = ( evidence: AiSearchPipelineEvidence[] ): Array> => evidence.slice(0, 12).map((item) => { const messageRef = Array.from(messageRefs.entries()).find( ([, value]) => value.conversationId === item.conversationId && value.messageId === item.messageId )?.[0] const conversationRef = refsByConversation.get(item.conversationId) return { messageRef: messageRef || '', conversationRef: conversationRef || '', sender: item.sender, time: messageTime(item.timestamp), preview: item.text.replace(/\s+/g, ' ').slice(0, 180) } }) const search = async ( terms: string[], conversationIds: string[] | undefined, limit: number, startTime = initialPlan.timeRange.startTime, endTime?: number ): Promise => { const startedAt = Date.now() const result = await this.knowledge.search({ text: request.text, terms, conversationIds, startTime, endTime, limit }) knowledgeSearchMs += Date.now() - startedAt const resultTimings = result.timings || emptyKnowledgeSearchTimings() // A previously running Worker may return an older timing shape during a // desktop hot reload. Missing diagnostic fields must remain zero rather // than turning the whole search trace into NaN. searchTimings.workerIpcMs += resultTimings.workerIpcMs || 0 searchTimings.workerBootMs += resultTimings.workerBootMs || 0 searchTimings.dispatchMs += resultTimings.dispatchMs || 0 searchTimings.workerSqlMs += resultTimings.workerSqlMs || 0 searchTimings.responseSerializeMs += resultTimings.responseSerializeMs || 0 searchTimings.responseTransferMs += resultTimings.responseTransferMs || 0 searchTimings.ftsMs += resultTimings.ftsMs || 0 searchTimings.chunkExpandMs += resultTimings.chunkExpandMs || 0 searchTimings.messageLoadMs += resultTimings.messageLoadMs || 0 searchTimings.rankingMs += resultTimings.rankingMs || 0 searchTimings.totalMs += resultTimings.totalMs || 0 return addSearchCandidates(result) } const execute = async ( action: Extract ): Promise => { rejectForbiddenAction(action) if (action.tool === 'search_people' || action.tool === 'search_conversations') { const query = boundedQuery(action.arguments.query) const limit = boundedLimit(action.arguments.limit, 10, 20) const peopleOnly = action.tool === 'search_people' const results = matchingContacts(query, peopleOnly) .slice(0, limit) .map((contact) => ({ conversationRef: addConversationRef(contact), name: contactLabel(contact), type: contact.type, matchReason: contactLabel(contact).toLocaleLowerCase() === query.toLocaleLowerCase() ? '名称匹配' : '名称相近' })) if (results.length && peopleOnly) { plan = { ...plan, contactNames: results.map((result) => result.name) } } return { summary: { total: results.length, results }, candidateCount: results.length } } if (action.tool === 'search_messages') { const query = boundedQuery(action.arguments.query) const limit = boundedLimit(action.arguments.limit, AGENT_SEARCH_LIMIT, AGENT_SEARCH_LIMIT) const contact = action.arguments.conversationRef ? resolveConversation(action.arguments.conversationRef) : undefined const evidence = await search( [query], contact ? [contact.md5] : sourceContacts.map((item) => item.md5), limit ) plan = { ...plan, keywords: [query], variants: [], intent: plan.intent === 'conversation_topic_search' || contact ? 'conversation_topic_search' : 'global_topic_search', source: 'ai' } return { summary: { total: evidence.length, messages: summarizeMessages(evidence) }, candidateCount: evidence.length } } if (action.tool === 'get_conversation_messages' || action.tool === 'get_messages_by_time') { const limit = boundedLimit(action.arguments.limit, AGENT_MESSAGE_LIMIT, AGENT_MESSAGE_LIMIT) const contact = action.arguments.conversationRef ? resolveConversation(action.arguments.conversationRef) : undefined const startTime = typeof action.arguments.startTime === 'number' && Number.isInteger(action.arguments.startTime) ? action.arguments.startTime : initialPlan.timeRange.startTime const endTime = typeof action.arguments.endTime === 'number' && Number.isInteger(action.arguments.endTime) ? action.arguments.endTime : undefined const minimumStartTime = initialPlan.timeRange.startTime const now = Math.floor(Date.now() / 1000) if ( (startTime !== undefined && (startTime < (minimumStartTime || 0) || startTime > now)) || (endTime !== undefined && (endTime > now || endTime < (minimumStartTime || 0))) || (endTime !== undefined && startTime !== undefined && endTime < startTime) ) { throw new Error('时间范围无效') } if (action.tool === 'get_conversation_messages' && !contact) { throw new Error('读取会话消息前必须先定位会话') } const evidence = await search( [], contact ? [contact.md5] : sourceContacts.map((item) => item.md5), limit, startTime, endTime ) const retrieval = lastSearchResult.conversationRetrieval return { summary: { totalMessages: retrieval?.totalMessages || evidence.length, chunks: retrieval?.chunkCount, candidateMessages: retrieval?.candidateMessages || evidence.length, systemMessagesDeprioritized: retrieval?.systemMessagesDeprioritized || 0, truncated: retrieval ? !retrieval.complete : false, messages: summarizeMessages(evidence) }, candidateCount: evidence.length, finalizeReason: action.tool === 'get_conversation_messages' && plan.intent === 'conversation_recall' && retrieval?.complete ? `已覆盖所选时间范围内 ${retrieval.totalMessages} 条消息,并整理为 ${retrieval.chunkCount} 个本地对话片段` : undefined } } const messageRef = action.arguments.messageRef if (typeof messageRef !== 'string') throw new Error('必须先通过消息检索取得上下文目标') const target = messageRefs.get(messageRef) if (!target || !contactsInScope.has(target.conversationId)) throw new Error('上下文目标不在本次允许范围内') const evidence = await search( [], [target.conversationId], boundedLimit(action.arguments.limit, 30, 50), Math.max(0, Math.floor(target.timestamp / 1000) - 15 * 60), Math.floor(target.timestamp / 1000) + 15 * 60 ) return { summary: { total: evidence.length, messages: summarizeMessages(evidence) }, candidateCount: evidence.length } } const outcome = await runControlledSearchAgent({ question: request.text, scopeLabel: initialPlan.scopeLabel, rangeLabel: initialPlan.rangeLabel, maxToolCalls: initialPlan.intent === 'conversation_recall' ? 2 : undefined, decide: async (prompt) => { const response = await this.aiProvider.chat([ { role: 'system', content: prompt }, { role: 'user', content: '请输出下一步受控检索 JSON。' } ]) return response.success ? response.data : undefined }, execute, onTrace: recordTrace }) if (outcome.status === 'invalid') { return { invalid: true, candidateEvidence: candidates, searchResult: lastSearchResult, plan, agent: { mode: 'fallback', toolCalls: outcome.toolCalls, trace, fallbackReason: outcome.reason }, searchTimings, knowledgeSearchMs } } return { candidateEvidence: candidates, searchResult: lastSearchResult, plan, agent: { mode: 'agent', toolCalls: outcome.toolCalls, trace }, searchTimings, knowledgeSearchMs } } private answerPrompt( query: string, plan: AiSearchPlan, totalMessages: number, evidence: AiSearchFinalEvidence[], aggregation: AiSearchAggregation, retrieval: AiSearchRetrievalContract ): string { const context = evidence .map( (item) => `[${item.id}]\nconversationId: ${item.conversationId}\nmessageId: ${item.messageId}\nsender: ${item.sender}\ntimestamp: ${messageTime(item.timestamp)}\ncontent: ${item.text}` ) .join('\n\n') const people = aggregation.people .map( (person) => `- ${person.name}:${person.messageCount} 条,${person.conversationCount} 个会话,最近 ${messageTime(person.lastMessageAt)},Evidence ${person.evidenceIds.join('、')}` ) .join('\n') const conversations = aggregation.conversations .map( (conversation) => `- ${conversation.name}:${conversation.messageCount} 条,${conversation.peopleCount} 人,Evidence ${conversation.evidenceIds.join('、')}` ) .join('\n') return `检索范围:${plan.scopeLabel},时间:${plan.rangeLabel} 用户问题:${query} 检索意图:${aiSearchIntentLabel(plan.intent)} 检索关键词:${plan.keywords.join('、') || '未提取到主题关键词'} 检索范围消息总数:${totalMessages} 程序已确认的事实:最终 Evidence ${aggregation.messageCount} 条,涉及 ${aggregation.peopleCount} 人、${aggregation.conversationCount} 个会话。 检索覆盖:来源消息 ${retrieval.sourceMessageCount ?? '未知'} 条;候选 ${retrieval.candidateCount} 条;覆盖状态 ${retrieval.sourceCoverage};完整=${retrieval.isComplete}。候选数不等于真实聊天总数,不能据此推断用户只聊了这些消息。 ${plan.intent === 'global_topic_search' ? `这是“按人物查找”问题。优先按以下人物统计作答,不要自行统计人数、会话数或消息数:\n${people || '无'}\n会话统计:\n${conversations || '无'}\n` : ''}以下是唯一允许引用的 Final Evidence。只能引用它们原样给出的 ID;不能使用其他编号: ${context}` } private buildRetrievalContract( plan: AiSearchPlan, resolvedContact: Contact | undefined, result: KnowledgeSearchIpcResult, candidates: AiSearchPipelineEvidence[], agent: AiSearchAgentRun ): AiSearchRetrievalContract { const identity = isIdentityIntent(plan.intent) const conversationRetrieval = result.conversationRetrieval const sourceMessageCount = conversationRetrieval?.totalMessages ?? (identity && resolvedContact ? result.totalMessages : undefined) const sourceCoverage = identity ? conversationRetrieval?.complete || (result.source === 'fallback' && Boolean(resolvedContact)) ? 'complete' : sourceMessageCount !== undefined ? 'partial' : 'unknown' : plan.intent === 'global_topic_search' || plan.intent === 'conversation_topic_search' ? 'keyword_match' : 'unknown' const isComplete = sourceCoverage === 'complete' return { intent: plan.intent, conversationId: resolvedContact?.md5, timeRange: plan.timeRange, retrievalMode: resolvedContact ? retrievalModeForIntent(plan.intent) : identity ? 'unresolved_identity' : retrievalModeForIntent(plan.intent), candidateCount: candidates.length, sourceMessageCount, sourceCoverage, isComplete, fallbackUsed: agent.mode === 'fallback' || result.source === 'fallback', fallbackReason: agent.fallbackReason || result.fallbackReason, suspicious: plan.intent === 'conversation_recall' && Boolean(resolvedContact) && Boolean(sourceMessageCount && sourceMessageCount > 1) && candidates.length <= 1 } } private errorMessage(stage: AiSearchProgressEvent['stage']): string { if (stage === 'query_understanding' || stage === 'search_plan_ready') return '无法理解搜索条件' if (stage === 'knowledge_searching') return '本地知识库暂时无法搜索' if (stage === 'evidence_ranking' || stage === 'evidence_ready' || stage === 'aggregation') return '无法整理原始消息证据' if (stage === 'ai_generating') return '证据已找到,但 AI 暂时无法生成回答' return '搜索暂时无法完成' } }