mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-21 21:47:00 +08:00
feat: 拆分代码
This commit is contained in:
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user