feat: 拆分代码

This commit is contained in:
Wxw-Gu
2026-08-18 16:49:25 +08:00
parent 3352936744
commit 366e2622cc
8 changed files with 1309 additions and 327 deletions
@@ -2,58 +2,45 @@ import React, { useMemo, useRef, useState } from 'react'
import * as Popover from '@radix-ui/react-popover' import * as Popover from '@radix-ui/react-popover'
import { aiSearchIntentLabel, aiSearchRangeStart } from '../../../../shared/ai-search' import { aiSearchIntentLabel, aiSearchRangeStart } from '../../../../shared/ai-search'
import type { import type {
AiSearchAggregation,
AiSearchAgentRun, AiSearchAgentRun,
AiSearchPipelineTimings,
AiSearchProgressEvent, AiSearchProgressEvent,
AiSearchProgressStage,
AiSearchTimeRange AiSearchTimeRange
} from '../../../../shared/ai-search' } from '../../../../shared/ai-search'
import type { Contact } from '../../../../shared/types'
import type { import type {
AISearchCacheRecord,
AISearchWorkspaceProps, AISearchWorkspaceProps,
EvidenceItem, EvidenceItem,
SearchProgressByStage,
SearchRange, SearchRange,
SearchScope, SearchScope,
SearchStage SearchStage,
SearchTrace
} from './searchTypes' } from './searchTypes'
import type { KnowledgeRuntimeStatus, KnowledgeVoiceCoverage } from '../../../../shared/knowledge' import type { KnowledgeRuntimeStatus } from '../../../../shared/knowledge'
import { import {
RANGE_LABELS, RANGE_LABELS,
SEARCH_ACTIVE_RESULT_KEY,
SEARCH_CACHE_KEY,
SEARCH_HISTORY_KEY,
buildSearchCacheKey, buildSearchCacheKey,
compactCacheItem,
currentTimestamp,
formatMessageTime, formatMessageTime,
messageIdentity, messageIdentity,
messageText, messageText,
parseSearchCacheKey, senderName
readSearchCache,
readSearchCacheByQuery,
senderName,
writeSearchCache
} from './searchUtils' } from './searchUtils'
import { markdownToPlainText, renderMarkdown } from './searchMarkdown' import { markdownToPlainText, renderMarkdown } from './searchMarkdown'
import {
type SearchTrace = { contactLabel,
knowledgeMessages: number formatBytes,
retrievedEvidence: number formatDuration,
finalEvidence: number formatMeasuredDuration,
timings: AiSearchPipelineTimings formatSearchTraceOverview,
contextEvidence: number knowledgeStateLabel
inputTokens?: number } from './searchFormatters'
inputTokensEstimated: boolean import {
aggregation: AiSearchAggregation mapEvidenceSenderNames,
invalidCitationIds: string[] mapPipelineEvidence,
agent: AiSearchAgentRun mapSearchResultToTrace
voiceCoverage?: KnowledgeVoiceCoverage } from './searchMappers'
} import { createSearchResultResetState } from './searchState'
import { useSearchHistory } from './hooks/useSearchHistory'
type SearchProgressByStage = Partial<Record<AiSearchProgressStage, AiSearchProgressEvent>>
type ExternalProviderConsent = { type ExternalProviderConsent = {
providerName: string providerName: string
@@ -62,44 +49,6 @@ type ExternalProviderConsent = {
const EVIDENCE_PAGE_SIZE = 8 const EVIDENCE_PAGE_SIZE = 8
const formatBytes = (bytes: number): string => {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
return `${(bytes / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`
}
const formatDuration = (milliseconds: number): string =>
milliseconds >= 1000 ? `${(milliseconds / 1000).toFixed(1)}s` : `${milliseconds}ms`
const formatMeasuredDuration = (milliseconds: number | undefined): string =>
milliseconds === undefined ? '未测量' : formatDuration(milliseconds)
const knowledgeStateLabel = (status: KnowledgeRuntimeStatus | null): string => {
if (!status) return '读取中'
return {
unavailable: '未建立',
building: '建立中',
syncing: '增量同步',
ready: '已同步',
error: '异常'
}[status.state]
}
const contactLabel = (contact: Contact | null | undefined): string =>
contact?.m_nsNickName ||
contact?.remark ||
contact?.wechatNickname ||
contact?.m_nsUsrName ||
'未选择会话'
const fallbackEvidenceContact = (conversationId: string): Contact => ({
md5: conversationId,
m_nsUsrName: conversationId,
m_nsNickName: '未加载的会话',
type: conversationId.endsWith('@chatroom') ? 'group' : 'user'
})
export function AISearchWorkspace({ export function AISearchWorkspace({
contacts, contacts,
selectedContact, selectedContact,
@@ -125,16 +74,6 @@ export function AISearchWorkspace({
const [selectedEvidence, setSelectedEvidence] = useState(0) const [selectedEvidence, setSelectedEvidence] = useState(0)
const [analysisError, setAnalysisError] = useState('') const [analysisError, setAnalysisError] = useState('')
const [messageCount, setMessageCount] = useState(0) const [messageCount, setMessageCount] = useState(0)
const [history, setHistory] = useState<string[]>(() => {
try {
const stored = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '[]')
return Array.isArray(stored)
? stored.filter((item): item is string => typeof item === 'string')
: []
} catch {
return []
}
})
const [senderNames, setSenderNames] = useState<Record<string, string>>({}) const [senderNames, setSenderNames] = useState<Record<string, string>>({})
const [cachedAt, setCachedAt] = useState(0) const [cachedAt, setCachedAt] = useState(0)
const [knowledgeStatus, setKnowledgeStatus] = useState<KnowledgeRuntimeStatus | null>(null) const [knowledgeStatus, setKnowledgeStatus] = useState<KnowledgeRuntimeStatus | null>(null)
@@ -148,7 +87,6 @@ export function AISearchWorkspace({
const [debugPanelOpen, setDebugPanelOpen] = useState(false) const [debugPanelOpen, setDebugPanelOpen] = useState(false)
const [debugEntries, setDebugEntries] = useState<string[]>([]) const [debugEntries, setDebugEntries] = useState<string[]>([])
const [appLogPath, setAppLogPath] = useState('') const [appLogPath, setAppLogPath] = useState('')
const bypassCacheRef = useRef(false)
const searchRequestIdRef = useRef('') const searchRequestIdRef = useRef('')
const knowledgeSyncingRef = useRef(false) const knowledgeSyncingRef = useRef(false)
const composerRef = useRef<HTMLTextAreaElement>(null) const composerRef = useRef<HTMLTextAreaElement>(null)
@@ -162,6 +100,63 @@ export function AISearchWorkspace({
[evidenceCollection, visibleEvidenceCount] [evidenceCollection, visibleEvidenceCount]
) )
const {
history,
rememberQuery,
restoreHistoryQuery,
removeHistoryQuery,
applyCachedResult,
readCachedResult,
persistSearchResult,
clearActiveResult,
skipNextCache,
consumeCacheBypass,
clearCacheBypass
} = useSearchHistory({
query,
scope,
range,
conversationContactMd5:
allContacts.find((contact) => contact.md5 === (scopeContactMd5 || selectedContact?.md5))
?.md5 ||
selectedContact?.md5 ||
'',
evidencePageSize: EVIDENCE_PAGE_SIZE,
setQuery,
setScope,
setScopeContactMd5,
setRange,
setTimeRangeOverride,
setResultQuery,
setAnswer,
setEvidence,
setEvidenceCollection,
setVisibleEvidenceCount,
setSenderNames,
setMessageCount,
setCachedAt,
setAnalysisError,
setStage,
setSelectedEvidence,
setHistoryOpen,
onNotice
})
const resetSearchResult = (): void => {
const reset = createSearchResultResetState()
setAnalysisError(reset.analysisError)
setAnswer(reset.answer)
setEvidence(reset.evidence)
setEvidenceCollection(reset.evidenceCollection)
setVisibleEvidenceCount(reset.visibleEvidenceCount)
setSelectedEvidence(reset.selectedEvidence)
setCachedAt(reset.cachedAt)
setSearchTrace(reset.searchTrace)
setSearchProgress(reset.searchProgress)
setAgentTrace(reset.agentTrace)
setSearchDetailsOpen(reset.searchDetailsOpen)
}
const focusEvidence = (index: number): void => { const focusEvidence = (index: number): void => {
if (!Number.isInteger(index) || index < 0 || index >= evidenceCollection.length) return if (!Number.isInteger(index) || index < 0 || index >= evidenceCollection.length) return
setVisibleEvidenceCount((current) => Math.max(current, index + 1)) setVisibleEvidenceCount((current) => Math.max(current, index + 1))
@@ -210,35 +205,6 @@ export function AISearchWorkspace({
}) })
}, [evidenceFlash]) }, [evidenceFlash])
React.useEffect(() => {
try {
const cacheKey = sessionStorage.getItem(SEARCH_ACTIVE_RESULT_KEY)
if (!cacheKey) return
const cached = readSearchCache(cacheKey)
const location = parseSearchCacheKey(cacheKey)
if (!cached || !location) {
sessionStorage.removeItem(SEARCH_ACTIVE_RESULT_KEY)
return
}
setQuery(location.query)
setScope(location.scope)
setScopeContactMd5(location.contactMd5)
setRange(location.range)
setTimeRangeOverride({
startTime: aiSearchRangeStart(location.range),
endTime: undefined,
label: RANGE_LABELS[location.range],
reason: '恢复上次查看的搜索结果',
source: 'user_selected'
})
setAnalysisError('')
applyCachedResult(cached, location.query)
setStage('result')
} catch {
sessionStorage.removeItem(SEARCH_ACTIVE_RESULT_KEY)
}
}, [])
React.useEffect(() => { React.useEffect(() => {
void Promise.all([window.api.getSettings(), window.api.getAppLogPath()]).then( void Promise.all([window.api.getSettings(), window.api.getAppLogPath()]).then(
([settingsResult, logPath]) => { ([settingsResult, logPath]) => {
@@ -333,113 +299,6 @@ export function AISearchWorkspace({
} }
} }
const rememberQuery = (value: string): void => {
setHistory((current) => {
const next = [value, ...current.filter((item) => item !== value)].slice(0, 10)
try {
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next))
} catch {
// History persistence is optional and must not interrupt analysis.
}
return next
})
}
const removeHistoryQuery = (historyQuery: string): void => {
setHistory((current) => {
const next = current.filter((item) => item !== historyQuery)
try {
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next))
} catch {
// History persistence is optional and must not interrupt analysis.
}
return next
})
try {
const records = JSON.parse(
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
) as AISearchCacheRecord[]
const queryKey = historyQuery.trim().toLowerCase()
localStorage.setItem(
SEARCH_CACHE_KEY,
JSON.stringify(
records.filter((item) => {
try {
const keyParts = JSON.parse(item.key) as unknown
return !(
Array.isArray(keyParts) &&
typeof keyParts[3] === 'string' &&
keyParts[3] === queryKey
)
} catch {
return true
}
})
)
)
} catch {
// Cache cleanup is optional and must not interrupt the current workspace.
}
}
const applyCachedResult = (cached: AISearchCacheRecord, queryValue = query.trim()): void => {
const cachedCollection = cached.evidenceCollection || cached.evidence
setResultQuery(queryValue)
setAnswer(cached.answer)
setEvidence(cached.evidence)
setEvidenceCollection(cachedCollection)
setVisibleEvidenceCount(Math.min(EVIDENCE_PAGE_SIZE, cachedCollection.length))
setSenderNames(cached.senderNames)
setMessageCount(cached.messageCount)
setCachedAt(cached.createdAt)
rememberQuery(queryValue)
try {
sessionStorage.setItem(SEARCH_ACTIVE_RESULT_KEY, cached.key)
} catch {
// Result restoration is optional and must not block search.
}
}
const restoreHistoryQuery = (historyQuery: string): void => {
setQuery(historyQuery)
setSelectedEvidence(0)
setHistoryOpen(false)
const cacheKey = buildSearchCacheKey(
scope,
scope === 'conversation' ? activeContact?.md5 || '' : '',
range,
historyQuery
)
const cached = readSearchCache(cacheKey) || readSearchCacheByQuery(historyQuery)?.record || null
if (!cached) {
setAnswer('')
setEvidence([])
setEvidenceCollection([])
setVisibleEvidenceCount(0)
setCachedAt(0)
setStage('idle')
onNotice('已填入历史问题,点击开始分析可重新查询最新消息')
return
}
const cachedLocation = parseSearchCacheKey(cached.key)
if (cachedLocation) {
setScope(cachedLocation.scope)
setRange(cachedLocation.range)
setScopeContactMd5(cachedLocation.contactMd5)
setTimeRangeOverride({
startTime: aiSearchRangeStart(cachedLocation.range),
endTime: undefined,
label: RANGE_LABELS[cachedLocation.range],
reason: '恢复历史搜索的时间范围',
source: 'user_selected'
})
}
setAnalysisError('')
applyCachedResult(cached, historyQuery)
setStage('result')
onNotice('已恢复这条历史问题的最近结果')
}
const ensureAiSearchDataConsent = async (requestId: string): Promise<boolean> => { const ensureAiSearchDataConsent = async (requestId: string): Promise<boolean> => {
const status = await window.api.getAiSearchProviderStatus() const status = await window.api.getAiSearchProviderStatus()
if (!status.configured || !status.requiresConsent) return true if (!status.configured || !status.requiresConsent) return true
@@ -510,8 +369,7 @@ export function AISearchWorkspace({
) )
let requestId = '' let requestId = ''
try { try {
const cached = bypassCacheRef.current ? null : readSearchCache(cacheKey) const cached = consumeCacheBypass() ? null : readCachedResult(cacheKey)
bypassCacheRef.current = false
if (cached) { if (cached) {
addDebugEntry('检索命中缓存', { addDebugEntry('检索命中缓存', {
scope, scope,
@@ -538,17 +396,7 @@ export function AISearchWorkspace({
return return
} }
setStage('loading') setStage('loading')
setAnalysisError('') resetSearchResult()
setAnswer('')
setEvidence([])
setEvidenceCollection([])
setVisibleEvidenceCount(0)
setSelectedEvidence(0)
setCachedAt(0)
setSearchTrace(null)
setSearchProgress({})
setAgentTrace([])
setSearchDetailsOpen(false)
searchRequestIdRef.current = requestId searchRequestIdRef.current = requestId
const searchResult = await window.api.runAiSearch({ const searchResult = await window.api.runAiSearch({
requestId, requestId,
@@ -571,61 +419,18 @@ export function AISearchWorkspace({
setStage('idle') setStage('idle')
return return
} }
const contactsById = new Map(allContacts.map((contact) => [contact.md5, contact])) const evidenceItems = mapPipelineEvidence(searchResult.evidence, allContacts)
const toEvidenceItem = (item: (typeof searchResult.evidence)[number]): EvidenceItem => { const collectionItems = mapPipelineEvidence(
// Contacts may still be paging in while the derived database already searchResult.evidenceCollection || searchResult.evidence,
// has a valid conversation id. Evidence must never be discarded just allContacts
// because the renderer directory is temporarily incomplete. )
const contact = contactsById.get(item.conversationId) || { setSearchTrace(mapSearchResultToTrace(searchResult, evidenceItems.length))
...fallbackEvidenceContact(item.conversationId),
m_nsNickName: item.conversationName,
type: item.conversationType
}
return {
evidenceId: item.id,
sourceKind: item.sourceKind,
contact,
message: {
id: item.messageId,
from: item.senderId || 'user',
type: item.sourceKind === 'voice' ? '语音转写' : '检索消息',
datetime: new Date(item.timestamp).toLocaleString('zh-CN', { hour12: false }),
content: item.text,
isSender: item.sender === '我',
name: item.sender,
senderId: item.senderId,
createTime: Math.floor(item.timestamp / 1000)
}
}
}
const evidenceItems: EvidenceItem[] = searchResult.evidence.map(toEvidenceItem)
const collectionItems: EvidenceItem[] = (
searchResult.evidenceCollection || searchResult.evidence
).map(toEvidenceItem)
setSearchTrace({
knowledgeMessages: searchResult.knowledge.indexedMessageCount,
retrievedEvidence: searchResult.candidateEvidenceCount,
finalEvidence: evidenceItems.length,
timings: searchResult.timings,
contextEvidence: searchResult.contextEvidenceCount,
inputTokens: searchResult.ai?.inputTokens,
inputTokensEstimated: searchResult.ai?.inputTokensEstimated || false,
aggregation: searchResult.aggregation,
invalidCitationIds: searchResult.citationValidation?.invalidCitationIds || [],
agent: searchResult.agent,
voiceCoverage: searchResult.knowledge.voiceCoverage
})
setAgentTrace(searchResult.agent.trace) setAgentTrace(searchResult.agent.trace)
setEvidence(evidenceItems) setEvidence(evidenceItems)
setEvidenceCollection(collectionItems) setEvidenceCollection(collectionItems)
setVisibleEvidenceCount(Math.min(EVIDENCE_PAGE_SIZE, collectionItems.length)) setVisibleEvidenceCount(Math.min(EVIDENCE_PAGE_SIZE, collectionItems.length))
setSenderNames( const nextSenderNames = mapEvidenceSenderNames(evidenceItems)
Object.fromEntries( setSenderNames(nextSenderNames)
evidenceItems
.filter(({ message }) => Boolean(message.senderId && message.name))
.map(({ message }) => [message.senderId as string, message.name as string])
)
)
setMessageCount(searchResult.knowledge.totalMessages) setMessageCount(searchResult.knowledge.totalMessages)
if (searchResult.status === 'no_evidence') { if (searchResult.status === 'no_evidence') {
setAnalysisError(`${RANGE_LABELS[effectiveRange]}内没有找到与问题相关的聊天消息。`) setAnalysisError(`${RANGE_LABELS[effectiveRange]}内没有找到与问题相关的聊天消息。`)
@@ -651,26 +456,14 @@ export function AISearchWorkspace({
setResultQuery(normalizedQuery) setResultQuery(normalizedQuery)
setAnswer(searchResult.answer) setAnswer(searchResult.answer)
rememberQuery(normalizedQuery) rememberQuery(normalizedQuery)
const cacheRecord: AISearchCacheRecord = { persistSearchResult({
version: 3,
key: cacheKey, key: cacheKey,
createdAt: currentTimestamp(),
answer: searchResult.answer, answer: searchResult.answer,
evidence: evidenceItems.map(compactCacheItem), evidence: evidenceItems,
evidenceCollection: collectionItems.map(compactCacheItem), evidenceCollection: collectionItems,
senderNames: Object.fromEntries( senderNames: nextSenderNames,
evidenceItems
.filter(({ message }) => Boolean(message.senderId && message.name))
.map(({ message }) => [message.senderId as string, message.name as string])
),
messageCount: searchResult.knowledge.totalMessages messageCount: searchResult.knowledge.totalMessages
} })
writeSearchCache(cacheRecord)
try {
sessionStorage.setItem(SEARCH_ACTIVE_RESULT_KEY, cacheRecord.key)
} catch {
// Result restoration is optional and must not block search.
}
setStage('result') setStage('result')
} catch (error) { } catch (error) {
if (requestId && searchRequestIdRef.current !== requestId) return if (requestId && searchRequestIdRef.current !== requestId) return
@@ -690,26 +483,12 @@ export function AISearchWorkspace({
} }
const startNewQuestion = (): void => { const startNewQuestion = (): void => {
bypassCacheRef.current = false clearCacheBypass()
setQuery('') setQuery('')
setResultQuery('') setResultQuery('')
setStage('idle') setStage('idle')
setAnswer('') resetSearchResult()
setEvidence([]) clearActiveResult()
setEvidenceCollection([])
setVisibleEvidenceCount(0)
setSelectedEvidence(0)
setAnalysisError('')
setCachedAt(0)
setSearchTrace(null)
setSearchProgress({})
setAgentTrace([])
setSearchDetailsOpen(false)
try {
sessionStorage.removeItem(SEARCH_ACTIVE_RESULT_KEY)
} catch {
// Session restoration is optional and must not block a fresh question.
}
composerRef.current?.focus() composerRef.current?.focus()
} }
@@ -1012,14 +791,18 @@ export function AISearchWorkspace({
{searchTrace?.retrievedEvidence || 0} {evidence.length} Evidence {searchTrace?.retrievedEvidence || 0} {evidence.length} Evidence
{cachedAt ? ' · 已使用缓存' : ''} {cachedAt ? ' · 已使用缓存' : ''}
</p> </p>
{searchTrace && ( {searchTrace &&
<div className="ai-search-trace" aria-label="本次检索追踪"> (() => {
<span> {formatDuration(searchTrace.timings.totalMs)}</span> const overview = formatSearchTraceOverview(searchTrace)
<span> {formatDuration(searchTrace.timings.knowledgeSearchMs)}</span> return (
<span>AI {formatDuration(searchTrace.timings.aiGenerationMs)}</span> <div className="ai-search-trace" aria-label="本次检索追踪">
<span> {searchTrace.contextEvidence} </span> <span> {overview.totalDuration}</span>
</div> <span> {overview.knowledgeDuration}</span>
)} <span>AI {overview.aiDuration}</span>
<span> {overview.contextEvidence}</span>
</div>
)
})()}
{renderSearchDetails()} {renderSearchDetails()}
</div> </div>
<div className="ai-search-result-actions"> <div className="ai-search-result-actions">
@@ -1032,7 +815,7 @@ export function AISearchWorkspace({
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
bypassCacheRef.current = true skipNextCache()
void runAnalysis() void runAnalysis()
}} }}
title="跳过缓存并重新读取聊天记录" title="跳过缓存并重新读取聊天记录"
@@ -1087,7 +870,7 @@ export function AISearchWorkspace({
} }
: undefined : undefined
) )
bypassCacheRef.current = true skipNextCache()
void runAnalysis(undefined, { void runAnalysis(undefined, {
range: expandToAll ? 'all' : '30d', range: expandToAll ? 'all' : '30d',
timeRangeOverride: expandToAll timeRangeOverride: expandToAll
@@ -0,0 +1,295 @@
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import { aiSearchRangeStart } from '../../../../../shared/ai-search'
import type { AiSearchTimeRange } from '../../../../../shared/ai-search'
import {
RANGE_LABELS,
SEARCH_ACTIVE_RESULT_KEY,
SEARCH_CACHE_KEY,
SEARCH_HISTORY_KEY,
buildSearchCacheKey,
parseSearchCacheKey,
readSearchCache,
readSearchCacheByQuery,
writeSearchCache
} from '../searchUtils'
import { createSearchCacheRecord, mapCacheRecordToResult } from '../searchMappers'
import type {
AISearchCacheRecord,
EvidenceItem,
SearchRange,
SearchScope,
SearchStage
} from '../searchTypes'
const DEFAULT_EVIDENCE_PAGE_SIZE = 8
type UseSearchHistoryOptions = {
query: string
scope: SearchScope
range: SearchRange
conversationContactMd5: string
evidencePageSize?: number
setQuery: Dispatch<SetStateAction<string>>
setScope: Dispatch<SetStateAction<SearchScope>>
setScopeContactMd5: Dispatch<SetStateAction<string>>
setRange: Dispatch<SetStateAction<SearchRange>>
setTimeRangeOverride: Dispatch<SetStateAction<AiSearchTimeRange | undefined>>
setResultQuery: Dispatch<SetStateAction<string>>
setAnswer: Dispatch<SetStateAction<string>>
setEvidence: Dispatch<SetStateAction<EvidenceItem[]>>
setEvidenceCollection: Dispatch<SetStateAction<EvidenceItem[]>>
setVisibleEvidenceCount: Dispatch<SetStateAction<number>>
setSenderNames: Dispatch<SetStateAction<Record<string, string>>>
setMessageCount: Dispatch<SetStateAction<number>>
setCachedAt: Dispatch<SetStateAction<number>>
setAnalysisError: Dispatch<SetStateAction<string>>
setStage: Dispatch<SetStateAction<SearchStage>>
setSelectedEvidence: Dispatch<SetStateAction<number>>
setHistoryOpen: Dispatch<SetStateAction<boolean>>
onNotice: (message: string) => void
}
type PersistSearchResultInput = {
key: string
answer: string
evidence: EvidenceItem[]
evidenceCollection: EvidenceItem[]
senderNames: Record<string, string>
messageCount: number
}
export function useSearchHistory({
query,
scope,
range,
conversationContactMd5,
evidencePageSize = DEFAULT_EVIDENCE_PAGE_SIZE,
setQuery,
setScope,
setScopeContactMd5,
setRange,
setTimeRangeOverride,
setResultQuery,
setAnswer,
setEvidence,
setEvidenceCollection,
setVisibleEvidenceCount,
setSenderNames,
setMessageCount,
setCachedAt,
setAnalysisError,
setStage,
setSelectedEvidence,
setHistoryOpen,
onNotice
}: UseSearchHistoryOptions): {
history: string[]
rememberQuery: (value: string) => void
removeHistoryQuery: (historyQuery: string) => void
restoreHistoryQuery: (historyQuery: string) => void
applyCachedResult: (cached: AISearchCacheRecord, queryValue?: string) => void
readCachedResult: (cacheKey: string) => AISearchCacheRecord | null
persistSearchResult: (input: PersistSearchResultInput) => AISearchCacheRecord
clearActiveResult: () => void
skipNextCache: () => void
consumeCacheBypass: () => boolean
clearCacheBypass: () => void
} {
const [history, setHistory] = useState<string[]>(() => {
try {
const stored = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '[]')
return Array.isArray(stored)
? stored.filter((item): item is string => typeof item === 'string')
: []
} catch {
return []
}
})
const bypassCacheRef = useRef(false)
const rememberQuery = (value: string): void => {
setHistory((current) => {
const next = [value, ...current.filter((item) => item !== value)].slice(0, 10)
try {
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next))
} catch {
// History persistence is optional and must not interrupt analysis.
}
return next
})
}
const removeHistoryQuery = (historyQuery: string): void => {
setHistory((current) => {
const next = current.filter((item) => item !== historyQuery)
try {
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next))
} catch {
// History persistence is optional and must not interrupt analysis.
}
return next
})
try {
const records = JSON.parse(
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
) as AISearchCacheRecord[]
const queryKey = historyQuery.trim().toLowerCase()
localStorage.setItem(
SEARCH_CACHE_KEY,
JSON.stringify(
records.filter((item) => {
try {
const keyParts = JSON.parse(item.key) as unknown
return !(
Array.isArray(keyParts) &&
typeof keyParts[3] === 'string' &&
keyParts[3] === queryKey
)
} catch {
return true
}
})
)
)
} catch {
// Cache cleanup is optional and must not interrupt the current workspace.
}
}
const applyCachedResult = (cached: AISearchCacheRecord, queryValue = query.trim()): void => {
const mapped = mapCacheRecordToResult(cached, queryValue, evidencePageSize)
setResultQuery(mapped.resultQuery)
setAnswer(mapped.answer)
setEvidence(mapped.evidence)
setEvidenceCollection(mapped.evidenceCollection)
setVisibleEvidenceCount(mapped.visibleEvidenceCount)
setSenderNames(mapped.senderNames)
setMessageCount(mapped.messageCount)
setCachedAt(mapped.cachedAt)
rememberQuery(queryValue)
try {
sessionStorage.setItem(SEARCH_ACTIVE_RESULT_KEY, cached.key)
} catch {
// Result restoration is optional and must not block search.
}
}
const restoreHistoryQuery = (historyQuery: string): void => {
setQuery(historyQuery)
setSelectedEvidence(0)
setHistoryOpen(false)
const cacheKey = buildSearchCacheKey(
scope,
scope === 'conversation' ? conversationContactMd5 : '',
range,
historyQuery
)
const cached = readSearchCache(cacheKey) || readSearchCacheByQuery(historyQuery)?.record || null
if (!cached) {
setAnswer('')
setEvidence([])
setEvidenceCollection([])
setVisibleEvidenceCount(0)
setCachedAt(0)
setStage('idle')
onNotice('已填入历史问题,点击开始分析可重新查询最新消息')
return
}
const cachedLocation = parseSearchCacheKey(cached.key)
if (cachedLocation) {
setScope(cachedLocation.scope)
setRange(cachedLocation.range)
setScopeContactMd5(cachedLocation.contactMd5)
setTimeRangeOverride({
startTime: aiSearchRangeStart(cachedLocation.range),
endTime: undefined,
label: RANGE_LABELS[cachedLocation.range],
reason: '恢复历史搜索的时间范围',
source: 'user_selected'
})
}
setAnalysisError('')
applyCachedResult(cached, historyQuery)
setStage('result')
onNotice('已恢复这条历史问题的最近结果')
}
const persistSearchResult = (input: PersistSearchResultInput): AISearchCacheRecord => {
const record = createSearchCacheRecord({
...input,
createdAt: Date.now()
})
writeSearchCache(record)
try {
sessionStorage.setItem(SEARCH_ACTIVE_RESULT_KEY, record.key)
} catch {
// Result persistence is optional and must not block search.
}
return record
}
const clearActiveResult = (): void => {
try {
sessionStorage.removeItem(SEARCH_ACTIVE_RESULT_KEY)
} catch {
// Result cleanup is optional and must not interrupt the current workspace.
}
}
const skipNextCache = (): void => {
bypassCacheRef.current = true
}
const consumeCacheBypass = (): boolean => {
const shouldBypass = bypassCacheRef.current
bypassCacheRef.current = false
return shouldBypass
}
const clearCacheBypass = (): void => {
bypassCacheRef.current = false
}
useEffect(() => {
try {
const cacheKey = sessionStorage.getItem(SEARCH_ACTIVE_RESULT_KEY)
if (!cacheKey) return
const cached = readSearchCache(cacheKey)
const location = parseSearchCacheKey(cacheKey)
if (!cached || !location) {
sessionStorage.removeItem(SEARCH_ACTIVE_RESULT_KEY)
return
}
setQuery(location.query)
setScope(location.scope)
setScopeContactMd5(location.contactMd5)
setRange(location.range)
setTimeRangeOverride({
startTime: aiSearchRangeStart(location.range),
endTime: undefined,
label: RANGE_LABELS[location.range],
reason: '恢复上次查看的搜索结果',
source: 'user_selected'
})
setAnalysisError('')
applyCachedResult(cached, location.query)
setStage('result')
} catch {
sessionStorage.removeItem(SEARCH_ACTIVE_RESULT_KEY)
}
}, [])
return {
history,
rememberQuery,
removeHistoryQuery,
restoreHistoryQuery,
applyCachedResult,
readCachedResult: readSearchCache,
persistSearchResult,
clearActiveResult,
skipNextCache,
consumeCacheBypass,
clearCacheBypass
}
}
@@ -0,0 +1,51 @@
import type { KnowledgeRuntimeStatus } from '../../../../shared/knowledge'
import type { Contact } from '../../../../shared/types'
import type { SearchTrace } from './searchTypes'
export const formatBytes = (bytes: number): string => {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
return `${(bytes / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`
}
export const formatDuration = (milliseconds: number): string =>
milliseconds >= 1000 ? `${(milliseconds / 1000).toFixed(1)}s` : `${milliseconds}ms`
export const formatMeasuredDuration = (milliseconds: number | undefined): string =>
milliseconds === undefined ? '未测量' : formatDuration(milliseconds)
export const formatEvidenceTimestamp = (timestamp: number): string =>
new Date(timestamp).toLocaleString('zh-CN', { hour12: false })
export const knowledgeStateLabel = (status: KnowledgeRuntimeStatus | null): string => {
if (!status) return '读取中'
return {
unavailable: '未建立',
building: '建立中',
syncing: '增量同步',
ready: '已同步',
error: '异常'
}[status.state]
}
export const contactLabel = (contact: Contact | null | undefined): string =>
contact?.m_nsNickName ||
contact?.remark ||
contact?.wechatNickname ||
contact?.m_nsUsrName ||
'未选择会话'
export const formatSearchTraceOverview = (
trace: SearchTrace
): {
totalDuration: string
knowledgeDuration: string
aiDuration: string
contextEvidence: string
} => ({
totalDuration: formatDuration(trace.timings.totalMs),
knowledgeDuration: formatDuration(trace.timings.knowledgeSearchMs),
aiDuration: formatDuration(trace.timings.aiGenerationMs),
contextEvidence: `${trace.contextEvidence}`
})
@@ -0,0 +1,125 @@
import type { AiSearchFinalEvidence, AiSearchPipelineResult } from '../../../../shared/ai-search'
import type { Contact } from '../../../../shared/types'
import { compactCacheItem } from './searchUtils'
import type { AISearchCacheRecord, EvidenceItem, SearchTrace } from './searchTypes'
import { formatEvidenceTimestamp } from './searchFormatters'
const fallbackEvidenceContact = (conversationId: string): Contact => ({
md5: conversationId,
m_nsUsrName: conversationId,
m_nsNickName: '未加载的会话',
type: conversationId.endsWith('@chatroom') ? 'group' : 'user'
})
export const mapPipelineEvidenceItem = (
item: AiSearchFinalEvidence,
contactsById: ReadonlyMap<string, Contact>
): EvidenceItem => {
const contact = contactsById.get(item.conversationId) || {
...fallbackEvidenceContact(item.conversationId),
m_nsNickName: item.conversationName,
type: item.conversationType
}
return {
evidenceId: item.id,
sourceKind: item.sourceKind,
contact,
message: {
id: item.messageId,
from: item.senderId || 'user',
type: item.sourceKind === 'voice' ? '语音转写' : '检索消息',
datetime: formatEvidenceTimestamp(item.timestamp),
content: item.text,
isSender: item.sender === '我',
name: item.sender,
senderId: item.senderId,
createTime: Math.floor(item.timestamp / 1000)
}
}
}
export const mapPipelineEvidence = (
items: AiSearchFinalEvidence[],
contacts: Contact[]
): EvidenceItem[] => {
const contactsById = new Map(contacts.map((contact) => [contact.md5, contact]))
return items.map((item) => mapPipelineEvidenceItem(item, contactsById))
}
export const mapEvidenceSenderNames = (items: EvidenceItem[]): Record<string, string> =>
Object.fromEntries(
items
.filter(({ message }) => Boolean(message.senderId && message.name))
.map(({ message }) => [message.senderId as string, message.name as string])
)
export const mapSearchResultToTrace = (
result: AiSearchPipelineResult,
finalEvidenceCount: number
): SearchTrace => ({
knowledgeMessages: result.knowledge.indexedMessageCount,
retrievedEvidence: result.candidateEvidenceCount,
finalEvidence: finalEvidenceCount,
timings: result.timings,
contextEvidence: result.contextEvidenceCount,
inputTokens: result.ai?.inputTokens,
inputTokensEstimated: result.ai?.inputTokensEstimated || false,
aggregation: result.aggregation,
invalidCitationIds: result.citationValidation?.invalidCitationIds || [],
agent: result.agent,
voiceCoverage: result.knowledge.voiceCoverage
})
export const mapCacheRecordToResult = (
cached: AISearchCacheRecord,
queryValue: string,
evidencePageSize: number
): {
resultQuery: string
answer: string
evidence: EvidenceItem[]
evidenceCollection: EvidenceItem[]
visibleEvidenceCount: number
senderNames: Record<string, string>
messageCount: number
cachedAt: number
} => {
const evidenceCollection = cached.evidenceCollection || cached.evidence
return {
resultQuery: queryValue,
answer: cached.answer,
evidence: cached.evidence,
evidenceCollection,
visibleEvidenceCount: Math.min(evidencePageSize, evidenceCollection.length),
senderNames: cached.senderNames,
messageCount: cached.messageCount,
cachedAt: cached.createdAt
}
}
export const createSearchCacheRecord = ({
key,
createdAt,
answer,
evidence,
evidenceCollection,
senderNames,
messageCount
}: {
key: string
createdAt: number
answer: string
evidence: EvidenceItem[]
evidenceCollection: EvidenceItem[]
senderNames: Record<string, string>
messageCount: number
}): AISearchCacheRecord => ({
version: 3,
key,
createdAt,
answer,
evidence: evidence.map(compactCacheItem),
evidenceCollection: evidenceCollection.map(compactCacheItem),
senderNames,
messageCount
})
@@ -0,0 +1,30 @@
import type { AiSearchAgentRun } from '../../../../shared/ai-search'
import type { EvidenceItem, SearchProgressByStage, SearchTrace } from './searchTypes'
export interface SearchResultResetState {
analysisError: string
answer: string
evidence: EvidenceItem[]
evidenceCollection: EvidenceItem[]
visibleEvidenceCount: number
selectedEvidence: number
cachedAt: number
searchTrace: SearchTrace | null
searchProgress: SearchProgressByStage
agentTrace: AiSearchAgentRun['trace']
searchDetailsOpen: boolean
}
export const createSearchResultResetState = (): SearchResultResetState => ({
analysisError: '',
answer: '',
evidence: [],
evidenceCollection: [],
visibleEvidenceCount: 0,
selectedEvidence: 0,
cachedAt: 0,
searchTrace: null,
searchProgress: {},
agentTrace: [],
searchDetailsOpen: false
})
@@ -1,5 +1,12 @@
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider' import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
import type { KnowledgeMessageKind } from '../../../../shared/knowledge' import type {
AiSearchAggregation,
AiSearchAgentRun,
AiSearchPipelineTimings,
AiSearchProgressEvent,
AiSearchProgressStage
} from '../../../../shared/ai-search'
import type { KnowledgeMessageKind, KnowledgeVoiceCoverage } from '../../../../shared/knowledge'
import type { Contact, Message } from '../../../../shared/types' import type { Contact, Message } from '../../../../shared/types'
export type SearchStage = 'idle' | 'loading' | 'result' | 'partial' | 'insufficient' export type SearchStage = 'idle' | 'loading' | 'result' | 'partial' | 'insufficient'
@@ -7,6 +14,22 @@ export type SearchScope = 'global' | 'groups' | 'contacts' | 'conversation'
export type SearchRange = 'today' | '7d' | '30d' | 'all' export type SearchRange = 'today' | '7d' | '30d' | 'all'
export type SearchIntent = 'general' | 'topic' | 'participants' | 'mixed' export type SearchIntent = 'general' | 'topic' | 'participants' | 'mixed'
export interface SearchTrace {
knowledgeMessages: number
retrievedEvidence: number
finalEvidence: number
timings: AiSearchPipelineTimings
contextEvidence: number
inputTokens?: number
inputTokensEstimated: boolean
aggregation: AiSearchAggregation
invalidCitationIds: string[]
agent: AiSearchAgentRun
voiceCoverage?: KnowledgeVoiceCoverage
}
export type SearchProgressByStage = Partial<Record<AiSearchProgressStage, AiSearchProgressEvent>>
export interface EvidenceItem { export interface EvidenceItem {
/** Program-owned Final Evidence ID. Cached legacy records may omit it. */ /** Program-owned Final Evidence ID. Cached legacy records may omit it. */
evidenceId?: string evidenceId?: string
@@ -0,0 +1,254 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useState } from 'react'
import { useSearchHistory } from '../../src/renderer/src/components/search/hooks/useSearchHistory'
import type { AiSearchTimeRange } from '../../src/shared/ai-search'
import type {
AISearchCacheRecord,
EvidenceItem,
SearchRange,
SearchScope,
SearchStage
} from '../../src/renderer/src/components/search/searchTypes'
import {
SEARCH_ACTIVE_RESULT_KEY,
SEARCH_CACHE_KEY,
SEARCH_HISTORY_KEY,
buildSearchCacheKey
} from '../../src/renderer/src/components/search/searchUtils'
import {
aiSearchContact,
aiSearchGroup,
makeCacheRecord,
makePipelineEvidence
} from './support/ai-search-fixtures'
const onNotice = vi.fn()
const useHistoryHarness = () => {
const [query, setQuery] = useState('当前问题')
const [scope, setScope] = useState<SearchScope>('global')
const [scopeContactMd5, setScopeContactMd5] = useState('')
const [range, setRange] = useState<SearchRange>('30d')
const [timeRangeOverride, setTimeRangeOverride] = useState<AiSearchTimeRange | undefined>()
const [resultQuery, setResultQuery] = useState('')
const [answer, setAnswer] = useState('')
const [evidence, setEvidence] = useState<EvidenceItem[]>([])
const [evidenceCollection, setEvidenceCollection] = useState<EvidenceItem[]>([])
const [visibleEvidenceCount, setVisibleEvidenceCount] = useState(0)
const [senderNames, setSenderNames] = useState<Record<string, string>>({})
const [messageCount, setMessageCount] = useState(0)
const [cachedAt, setCachedAt] = useState(0)
const [analysisError, setAnalysisError] = useState('')
const [stage, setStage] = useState<SearchStage>('idle')
const [selectedEvidence, setSelectedEvidence] = useState(0)
const [historyOpen, setHistoryOpen] = useState(true)
const history = useSearchHistory({
query,
scope,
range,
conversationContactMd5: scopeContactMd5 || aiSearchContact.md5,
setQuery,
setScope,
setScopeContactMd5,
setRange,
setTimeRangeOverride,
setResultQuery,
setAnswer,
setEvidence,
setEvidenceCollection,
setVisibleEvidenceCount,
setSenderNames,
setMessageCount,
setCachedAt,
setAnalysisError,
setStage,
setSelectedEvidence,
setHistoryOpen,
onNotice
})
return {
...history,
state: {
query,
scope,
scopeContactMd5,
range,
timeRangeOverride,
resultQuery,
answer,
evidence,
evidenceCollection,
visibleEvidenceCount,
senderNames,
messageCount,
cachedAt,
analysisError,
stage,
selectedEvidence,
historyOpen
}
}
}
const readCacheRecords = (): AISearchCacheRecord[] =>
JSON.parse(localStorage.getItem(SEARCH_CACHE_KEY) || '[]') as AISearchCacheRecord[]
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
vi.clearAllMocks()
})
describe('useSearchHistory', () => {
it('loads and filters the persisted history list', () => {
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(['问题 A', 42, null, '问题 B']))
const { result } = renderHook(() => useHistoryHarness())
expect(result.current.history).toEqual(['问题 A', '问题 B'])
})
it('restores a history cache with its original scope, range and time override', () => {
const query = '恢复历史问题'
const cached = makeCacheRecord({
query,
scope: 'conversation',
contactMd5: aiSearchGroup.md5,
range: '7d',
answer: '历史缓存答案',
evidence: [makePipelineEvidence(1, aiSearchGroup)]
}) as AISearchCacheRecord
localStorage.setItem(SEARCH_CACHE_KEY, JSON.stringify([cached]))
const { result } = renderHook(() => useHistoryHarness())
act(() => result.current.restoreHistoryQuery(query))
expect(result.current.state.query).toBe(query)
expect(result.current.state.scope).toBe('conversation')
expect(result.current.state.scopeContactMd5).toBe(aiSearchGroup.md5)
expect(result.current.state.range).toBe('7d')
expect(result.current.state.timeRangeOverride).toMatchObject({
label: '近 7 天',
reason: '恢复历史搜索的时间范围',
source: 'user_selected'
})
expect(result.current.state.answer).toBe('历史缓存答案')
expect(result.current.state.stage).toBe('result')
expect(result.current.state.historyOpen).toBe(false)
expect(onNotice).toHaveBeenCalledWith('已恢复这条历史问题的最近结果')
})
it('deletes a history item and all cache records for its normalized query', () => {
const deleted = makeCacheRecord({ query: '待删除问题', answer: '应删除' })
const retained = makeCacheRecord({ query: '保留问题', answer: '应保留' })
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(['待删除问题', '保留问题']))
localStorage.setItem(SEARCH_CACHE_KEY, JSON.stringify([deleted, retained]))
const { result } = renderHook(() => useHistoryHarness())
act(() => result.current.removeHistoryQuery(' 待删除问题 '))
expect(result.current.history).toEqual(['待删除问题', '保留问题'])
expect(readCacheRecords()).toEqual([retained])
act(() => result.current.removeHistoryQuery('待删除问题'))
expect(result.current.history).toEqual(['保留问题'])
expect(readCacheRecords()).toEqual([retained])
})
it('reads and applies a current cache hit while marking the active session result', () => {
const cached = makeCacheRecord({
query: '缓存命中问题',
answer: '缓存命中答案',
evidence: [makePipelineEvidence(1)]
}) as AISearchCacheRecord
localStorage.setItem(SEARCH_CACHE_KEY, JSON.stringify([cached]))
const { result } = renderHook(() => useHistoryHarness())
const found = result.current.readCachedResult(cached.key)
expect(found).toEqual(cached)
act(() => result.current.applyCachedResult(cached, '缓存命中问题'))
expect(result.current.state.answer).toBe('缓存命中答案')
expect(result.current.state.resultQuery).toBe('缓存命中问题')
expect(result.current.state.evidence).toHaveLength(1)
expect(sessionStorage.getItem(SEARCH_ACTIVE_RESULT_KEY)).toBe(cached.key)
})
it('preserves an intentionally incomplete cache collection instead of rebuilding it', () => {
const evidence = makeCacheRecord({
query: '不完整缓存',
evidence: [makePipelineEvidence(1)]
}).evidence as EvidenceItem[]
const cached: AISearchCacheRecord = {
version: 3,
key: buildSearchCacheKey('global', '', '30d', '不完整缓存'),
createdAt: 10,
answer: '不完整',
evidence,
evidenceCollection: [],
senderNames: {},
messageCount: 1
}
const { result } = renderHook(() => useHistoryHarness())
act(() => result.current.applyCachedResult(cached, '不完整缓存'))
expect(result.current.state.evidence).toHaveLength(1)
expect(result.current.state.evidenceCollection).toEqual([])
expect(result.current.state.visibleEvidenceCount).toBe(0)
})
it('falls back to legacy evidence when evidenceCollection is absent', () => {
const cached = makeCacheRecord({
query: '旧缓存问题',
evidence: [makePipelineEvidence(1)]
}) as AISearchCacheRecord
const { result } = renderHook(() => useHistoryHarness())
act(() => result.current.applyCachedResult(cached, '旧缓存问题'))
expect(result.current.state.evidenceCollection).toBe(result.current.state.evidence)
expect(result.current.state.visibleEvidenceCount).toBe(1)
})
it('does not reuse an old cache after the caller marks a new search as bypassing cache', () => {
const cached = makeCacheRecord({ query: '旧结果', answer: '旧答案' }) as AISearchCacheRecord
localStorage.setItem(SEARCH_CACHE_KEY, JSON.stringify([cached]))
const { result } = renderHook(() => useHistoryHarness())
act(() => result.current.skipNextCache())
const shouldBypass = result.current.consumeCacheBypass()
expect(shouldBypass).toBe(true)
expect(result.current.state.answer).toBe('')
expect(result.current.consumeCacheBypass()).toBe(false)
expect(result.current.readCachedResult(cached.key)).toEqual(cached)
})
it('persists a new compact cache record and its active session key', () => {
const { result } = renderHook(() => useHistoryHarness())
const evidence = makeCacheRecord({
query: '新搜索问题',
evidence: [makePipelineEvidence(1)]
}).evidence as EvidenceItem[]
act(() =>
result.current.persistSearchResult({
key: buildSearchCacheKey('global', '', '30d', '新搜索问题'),
answer: '新答案',
evidence,
evidenceCollection: evidence,
senderNames: { 'sender-1': '发送者 1' },
messageCount: 20
})
)
const records = readCacheRecords()
expect(records).toHaveLength(1)
expect(records[0].version).toBe(3)
expect(records[0].answer).toBe('新答案')
expect(sessionStorage.getItem(SEARCH_ACTIVE_RESULT_KEY)).toBe(records[0].key)
})
})
@@ -0,0 +1,421 @@
import { describe, expect, it } from 'vitest'
import type { AiSearchFinalEvidence, AiSearchPipelineResult } from '../../src/shared/ai-search'
import type { KnowledgeRuntimeStatus } from '../../src/shared/knowledge'
import type { Contact } from '../../src/shared/types'
import {
contactLabel,
formatBytes,
formatDuration,
formatEvidenceTimestamp,
formatMeasuredDuration,
formatSearchTraceOverview,
knowledgeStateLabel
} from '../../src/renderer/src/components/search/searchFormatters'
import {
createSearchCacheRecord,
mapCacheRecordToResult,
mapEvidenceSenderNames,
mapPipelineEvidence,
mapPipelineEvidenceItem,
mapSearchResultToTrace
} from '../../src/renderer/src/components/search/searchMappers'
import { createSearchResultResetState } from '../../src/renderer/src/components/search/searchState'
import type {
AISearchCacheRecord,
EvidenceItem,
SearchTrace
} from '../../src/renderer/src/components/search/searchTypes'
import {
aiSearchContact,
aiSearchGroup,
makeSearchResult
} from '../component/support/ai-search-fixtures'
const makeFinalEvidence = (
index: number,
overrides: Partial<AiSearchFinalEvidence> = {}
): AiSearchFinalEvidence => ({
id: `E${index}`,
chunkId: `chunk-${index}`,
conversationId: aiSearchContact.md5,
conversationName: aiSearchContact.m_nsNickName,
conversationType: aiSearchContact.type,
startTime: 1_700_000_000_000 + index * 1_000,
endTime: 1_700_000_000_000 + index * 1_000,
messageId: `message-${index}`,
senderId: `sender-${index}`,
sender: `发送者 ${index}`,
timestamp: 1_700_000_000_000 + index * 1_000,
messageIds: [`message-${index}`],
sourceKind: 'text',
text: `证据 ${index}`,
...overrides
})
const makeKnowledgeStatus = (state: KnowledgeRuntimeStatus['state']): KnowledgeRuntimeStatus => ({
accountId: 'account-1',
state,
indexedMessageCount: 0,
indexedChunkCount: 0,
sourceMessageCount: null,
processedMessages: 0,
totalMessages: null,
estimatedRemainingMs: null,
databaseBytes: 0,
walBytes: 0,
shmBytes: 0
})
const makeTrace = (overrides: Partial<SearchTrace> = {}): SearchTrace => ({
knowledgeMessages: 20,
retrievedEvidence: 10,
finalEvidence: 8,
timings: {
totalMs: 1_250,
knowledgeSearchMs: 250,
aiGenerationMs: 1_000
} as SearchTrace['timings'],
contextEvidence: 6,
inputTokensEstimated: false,
aggregation: {
messageCount: 8,
peopleCount: 1,
conversationCount: 1,
people: [],
conversations: []
},
invalidCitationIds: [],
agent: { mode: 'agent', toolCalls: 0, trace: [] },
...overrides
})
describe('AI Search workspace pure formatters', () => {
it('formats byte counts exactly as the workspace did', () => {
expect(formatBytes(0)).toBe('0 B')
expect(formatBytes(512)).toBe('512 B')
expect(formatBytes(1_536)).toBe('1.5 KB')
expect(formatBytes(2 * 1024 ** 3)).toBe('2.0 GB')
})
it('formats measured and unmeasured durations exactly as the workspace did', () => {
expect(formatDuration(999)).toBe('999ms')
expect(formatDuration(1_250)).toBe('1.3s')
expect(formatMeasuredDuration(undefined)).toBe('未测量')
expect(formatMeasuredDuration(1_000)).toBe('1.0s')
})
it('formats evidence timestamps with the existing zh-CN locale options', () => {
const timestamp = 1_700_000_001_000
expect(formatEvidenceTimestamp(timestamp)).toBe(
new Date(timestamp).toLocaleString('zh-CN', { hour12: false })
)
})
it('keeps every knowledge runtime state label unchanged', () => {
expect(knowledgeStateLabel(null)).toBe('读取中')
expect(knowledgeStateLabel(makeKnowledgeStatus('unavailable'))).toBe('未建立')
expect(knowledgeStateLabel(makeKnowledgeStatus('building'))).toBe('建立中')
expect(knowledgeStateLabel(makeKnowledgeStatus('syncing'))).toBe('增量同步')
expect(knowledgeStateLabel(makeKnowledgeStatus('ready'))).toBe('已同步')
expect(knowledgeStateLabel(makeKnowledgeStatus('error'))).toBe('异常')
})
it('keeps the existing contact label fallback order', () => {
expect(contactLabel(aiSearchContact)).toBe('测试会话')
expect(contactLabel({ ...aiSearchContact, m_nsNickName: '' })).toBe('测试联系人')
expect(
contactLabel({ ...aiSearchContact, m_nsNickName: '', remark: '', wechatNickname: '' })
).toBe('wxid_fixture')
expect(contactLabel(null)).toBe('未选择会话')
})
it('formats the compact search trace overview without changing labels or units', () => {
expect(formatSearchTraceOverview(makeTrace())).toEqual({
totalDuration: '1.3s',
knowledgeDuration: '250ms',
aiDuration: '1.0s',
contextEvidence: '6 条'
})
})
})
describe('AI Search pipeline evidence mapping', () => {
it('reuses the existing renderer contact object when the conversation is loaded', () => {
const item = makeFinalEvidence(1)
const mapped = mapPipelineEvidenceItem(item, new Map([[aiSearchContact.md5, aiSearchContact]]))
expect(mapped.contact).toBe(aiSearchContact)
expect(mapped).toEqual({
evidenceId: 'E1',
sourceKind: 'text',
contact: aiSearchContact,
message: {
id: 'message-1',
from: 'sender-1',
type: '检索消息',
datetime: formatEvidenceTimestamp(item.timestamp),
content: '证据 1',
isSender: false,
name: '发送者 1',
senderId: 'sender-1',
createTime: Math.floor(item.timestamp / 1_000)
}
})
})
it('creates the existing group fallback and voice presentation for an unloaded conversation', () => {
const item = makeFinalEvidence(2, {
conversationId: 'missing@chatroom',
conversationName: '未加载群',
conversationType: 'group',
sourceKind: 'voice',
sender: '我'
})
const mapped = mapPipelineEvidenceItem(item, new Map())
expect(mapped.contact).toEqual({
md5: 'missing@chatroom',
m_nsUsrName: 'missing@chatroom',
m_nsNickName: '未加载群',
type: 'group'
})
expect(mapped.message.type).toBe('语音转写')
expect(mapped.message.isSender).toBe(true)
})
it('creates the existing user fallback and sender default when senderId is absent', () => {
const item = makeFinalEvidence(3, {
conversationId: 'missing-user',
conversationName: '未加载联系人',
conversationType: 'user',
senderId: undefined
})
const mapped = mapPipelineEvidenceItem(item, new Map())
expect(mapped.contact.type).toBe('user')
expect(mapped.message.from).toBe('user')
expect(mapped.message.senderId).toBeUndefined()
})
it('maps a collection in order and builds sender names with the existing overwrite behavior', () => {
const items = [
makeFinalEvidence(1, { senderId: 'same-sender', sender: '旧名称' }),
makeFinalEvidence(2, { senderId: 'same-sender', sender: '新名称' }),
makeFinalEvidence(3, { senderId: undefined, sender: '无 ID' })
]
const mapped = mapPipelineEvidence(items, [aiSearchContact])
expect(mapped.map((item) => item.evidenceId)).toEqual(['E1', 'E2', 'E3'])
expect(mapEvidenceSenderNames(mapped)).toEqual({ 'same-sender': '新名称' })
})
})
describe('AI Search trace and cache mapping', () => {
it('maps pipeline trace fields and preserves the original default values', () => {
const result: AiSearchPipelineResult = makeSearchResult()
expect(mapSearchResultToTrace(result, 4)).toEqual({
knowledgeMessages: 20,
retrievedEvidence: 0,
finalEvidence: 4,
timings: result.timings,
contextEvidence: 0,
inputTokens: undefined,
inputTokensEstimated: false,
aggregation: result.aggregation,
invalidCitationIds: [],
agent: result.agent,
voiceCoverage: undefined
})
})
it('maps AI token, citation, and voice coverage details without transforming them', () => {
const result: AiSearchPipelineResult = makeSearchResult()
result.ai = {
providerName: 'provider',
modelName: 'model',
inputTokens: 321,
inputTokensEstimated: true
}
result.citationValidation = { status: 'sanitized', invalidCitationIds: ['E9'] }
result.knowledge.voiceCoverage = {
voiceMessageCount: 3,
transcribedVoiceCount: 2,
failedVoiceCount: 1,
voiceCoverageComplete: true
}
const trace = mapSearchResultToTrace(result, 2)
expect(trace.inputTokens).toBe(321)
expect(trace.inputTokensEstimated).toBe(true)
expect(trace.invalidCitationIds).toEqual(['E9'])
expect(trace.voiceCoverage).toBe(result.knowledge.voiceCoverage)
})
it('maps a current cache record and limits only the visible collection to one page', () => {
const evidence = mapPipelineEvidence([makeFinalEvidence(1)], [aiSearchContact])
const evidenceCollection = mapPipelineEvidence(
Array.from({ length: 10 }, (_, index) => makeFinalEvidence(index + 1)),
[aiSearchContact]
)
const cached: AISearchCacheRecord = {
version: 3,
key: 'cache-key',
createdAt: 123,
answer: '缓存答案',
evidence,
evidenceCollection,
senderNames: { 'sender-1': '发送者 1' },
messageCount: 99
}
const mapped = mapCacheRecordToResult(cached, '恢复问题', 8)
expect(mapped).toEqual({
resultQuery: '恢复问题',
answer: '缓存答案',
evidence,
evidenceCollection,
visibleEvidenceCount: 8,
senderNames: { 'sender-1': '发送者 1' },
messageCount: 99,
cachedAt: 123
})
})
it('falls back to legacy cache evidence when evidenceCollection is absent', () => {
const evidence = mapPipelineEvidence([makeFinalEvidence(1)], [aiSearchContact])
const cached: AISearchCacheRecord = {
version: 3,
key: 'legacy-cache-key',
createdAt: 456,
answer: '旧缓存',
evidence,
senderNames: {},
messageCount: 1
}
const mapped = mapCacheRecordToResult(cached, '旧问题', 8)
expect(mapped.evidenceCollection).toBe(evidence)
expect(mapped.visibleEvidenceCount).toBe(1)
})
it('creates the same compact version 3 cache record as the workspace did', () => {
const fullContact: Contact = { ...aiSearchGroup, avatar: 'avatar', remark: '不应写入缓存' }
const evidence: EvidenceItem[] = [
{
evidenceId: 'E1',
sourceKind: 'voice',
contact: fullContact,
message: {
id: 'message-1',
from: 'sender-1',
type: '语音转写',
datetime: '2023/11/14 22:13:21',
content: '证据 1',
isSender: false,
name: '发送者 1',
senderId: 'sender-1',
createTime: 1_700_000_001,
sessionId: '不应写入缓存'
}
}
]
expect(
createSearchCacheRecord({
key: 'cache-key',
createdAt: 789,
answer: '答案',
evidence,
evidenceCollection: evidence,
senderNames: { 'sender-1': '发送者 1' },
messageCount: 20
})
).toEqual({
version: 3,
key: 'cache-key',
createdAt: 789,
answer: '答案',
evidence: [
{
evidenceId: 'E1',
contact: {
md5: fullContact.md5,
m_nsUsrName: fullContact.m_nsUsrName,
m_nsNickName: fullContact.m_nsNickName,
type: 'group',
avatar: 'avatar'
},
message: {
id: 'message-1',
from: 'sender-1',
type: '语音转写',
datetime: '2023/11/14 22:13:21',
content: '证据 1',
isSender: false,
name: '发送者 1',
senderId: 'sender-1',
localId: undefined,
serverId: undefined,
createTime: 1_700_000_001
}
}
],
evidenceCollection: [
{
evidenceId: 'E1',
contact: {
md5: fullContact.md5,
m_nsUsrName: fullContact.m_nsUsrName,
m_nsNickName: fullContact.m_nsNickName,
type: 'group',
avatar: 'avatar'
},
message: {
id: 'message-1',
from: 'sender-1',
type: '语音转写',
datetime: '2023/11/14 22:13:21',
content: '证据 1',
isSender: false,
name: '发送者 1',
senderId: 'sender-1',
localId: undefined,
serverId: undefined,
createTime: 1_700_000_001
}
}
],
senderNames: { 'sender-1': '发送者 1' },
messageCount: 20
})
})
})
describe('AI Search result reset state', () => {
it('returns every existing search result reset value', () => {
expect(createSearchResultResetState()).toEqual({
analysisError: '',
answer: '',
evidence: [],
evidenceCollection: [],
visibleEvidenceCount: 0,
selectedEvidence: 0,
cachedAt: 0,
searchTrace: null,
searchProgress: {},
agentTrace: [],
searchDetailsOpen: false
})
})
it('returns independent arrays and progress objects for consecutive resets', () => {
const first = createSearchResultResetState()
const second = createSearchResultResetState()
expect(first.evidence).not.toBe(second.evidence)
expect(first.evidenceCollection).not.toBe(second.evidenceCollection)
expect(first.searchProgress).not.toBe(second.searchProgress)
expect(first.agentTrace).not.toBe(second.agentTrace)
})
})