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 { aiSearchIntentLabel, aiSearchRangeStart } from '../../../../shared/ai-search'
|
||||
import type {
|
||||
AiSearchAggregation,
|
||||
AiSearchAgentRun,
|
||||
AiSearchPipelineTimings,
|
||||
AiSearchProgressEvent,
|
||||
AiSearchProgressStage,
|
||||
AiSearchTimeRange
|
||||
} from '../../../../shared/ai-search'
|
||||
import type { Contact } from '../../../../shared/types'
|
||||
|
||||
import type {
|
||||
AISearchCacheRecord,
|
||||
AISearchWorkspaceProps,
|
||||
EvidenceItem,
|
||||
SearchProgressByStage,
|
||||
SearchRange,
|
||||
SearchScope,
|
||||
SearchStage
|
||||
SearchStage,
|
||||
SearchTrace
|
||||
} from './searchTypes'
|
||||
import type { KnowledgeRuntimeStatus, KnowledgeVoiceCoverage } from '../../../../shared/knowledge'
|
||||
import type { KnowledgeRuntimeStatus } from '../../../../shared/knowledge'
|
||||
import {
|
||||
RANGE_LABELS,
|
||||
SEARCH_ACTIVE_RESULT_KEY,
|
||||
SEARCH_CACHE_KEY,
|
||||
SEARCH_HISTORY_KEY,
|
||||
buildSearchCacheKey,
|
||||
compactCacheItem,
|
||||
currentTimestamp,
|
||||
formatMessageTime,
|
||||
messageIdentity,
|
||||
messageText,
|
||||
parseSearchCacheKey,
|
||||
readSearchCache,
|
||||
readSearchCacheByQuery,
|
||||
senderName,
|
||||
writeSearchCache
|
||||
senderName
|
||||
} from './searchUtils'
|
||||
import { markdownToPlainText, renderMarkdown } from './searchMarkdown'
|
||||
|
||||
type SearchTrace = {
|
||||
knowledgeMessages: number
|
||||
retrievedEvidence: number
|
||||
finalEvidence: number
|
||||
timings: AiSearchPipelineTimings
|
||||
contextEvidence: number
|
||||
inputTokens?: number
|
||||
inputTokensEstimated: boolean
|
||||
aggregation: AiSearchAggregation
|
||||
invalidCitationIds: string[]
|
||||
agent: AiSearchAgentRun
|
||||
voiceCoverage?: KnowledgeVoiceCoverage
|
||||
}
|
||||
|
||||
type SearchProgressByStage = Partial<Record<AiSearchProgressStage, AiSearchProgressEvent>>
|
||||
import {
|
||||
contactLabel,
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
formatMeasuredDuration,
|
||||
formatSearchTraceOverview,
|
||||
knowledgeStateLabel
|
||||
} from './searchFormatters'
|
||||
import {
|
||||
mapEvidenceSenderNames,
|
||||
mapPipelineEvidence,
|
||||
mapSearchResultToTrace
|
||||
} from './searchMappers'
|
||||
import { createSearchResultResetState } from './searchState'
|
||||
import { useSearchHistory } from './hooks/useSearchHistory'
|
||||
|
||||
type ExternalProviderConsent = {
|
||||
providerName: string
|
||||
@@ -62,44 +49,6 @@ type ExternalProviderConsent = {
|
||||
|
||||
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({
|
||||
contacts,
|
||||
selectedContact,
|
||||
@@ -125,16 +74,6 @@ export function AISearchWorkspace({
|
||||
const [selectedEvidence, setSelectedEvidence] = useState(0)
|
||||
const [analysisError, setAnalysisError] = useState('')
|
||||
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 [cachedAt, setCachedAt] = useState(0)
|
||||
const [knowledgeStatus, setKnowledgeStatus] = useState<KnowledgeRuntimeStatus | null>(null)
|
||||
@@ -148,7 +87,6 @@ export function AISearchWorkspace({
|
||||
const [debugPanelOpen, setDebugPanelOpen] = useState(false)
|
||||
const [debugEntries, setDebugEntries] = useState<string[]>([])
|
||||
const [appLogPath, setAppLogPath] = useState('')
|
||||
const bypassCacheRef = useRef(false)
|
||||
const searchRequestIdRef = useRef('')
|
||||
const knowledgeSyncingRef = useRef(false)
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null)
|
||||
@@ -162,6 +100,63 @@ export function AISearchWorkspace({
|
||||
[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 => {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= evidenceCollection.length) return
|
||||
setVisibleEvidenceCount((current) => Math.max(current, index + 1))
|
||||
@@ -210,35 +205,6 @@ export function AISearchWorkspace({
|
||||
})
|
||||
}, [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(() => {
|
||||
void Promise.all([window.api.getSettings(), window.api.getAppLogPath()]).then(
|
||||
([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 status = await window.api.getAiSearchProviderStatus()
|
||||
if (!status.configured || !status.requiresConsent) return true
|
||||
@@ -510,8 +369,7 @@ export function AISearchWorkspace({
|
||||
)
|
||||
let requestId = ''
|
||||
try {
|
||||
const cached = bypassCacheRef.current ? null : readSearchCache(cacheKey)
|
||||
bypassCacheRef.current = false
|
||||
const cached = consumeCacheBypass() ? null : readCachedResult(cacheKey)
|
||||
if (cached) {
|
||||
addDebugEntry('检索命中缓存', {
|
||||
scope,
|
||||
@@ -538,17 +396,7 @@ export function AISearchWorkspace({
|
||||
return
|
||||
}
|
||||
setStage('loading')
|
||||
setAnalysisError('')
|
||||
setAnswer('')
|
||||
setEvidence([])
|
||||
setEvidenceCollection([])
|
||||
setVisibleEvidenceCount(0)
|
||||
setSelectedEvidence(0)
|
||||
setCachedAt(0)
|
||||
setSearchTrace(null)
|
||||
setSearchProgress({})
|
||||
setAgentTrace([])
|
||||
setSearchDetailsOpen(false)
|
||||
resetSearchResult()
|
||||
searchRequestIdRef.current = requestId
|
||||
const searchResult = await window.api.runAiSearch({
|
||||
requestId,
|
||||
@@ -571,61 +419,18 @@ export function AISearchWorkspace({
|
||||
setStage('idle')
|
||||
return
|
||||
}
|
||||
const contactsById = new Map(allContacts.map((contact) => [contact.md5, contact]))
|
||||
const toEvidenceItem = (item: (typeof searchResult.evidence)[number]): EvidenceItem => {
|
||||
// Contacts may still be paging in while the derived database already
|
||||
// has a valid conversation id. Evidence must never be discarded just
|
||||
// because the renderer directory is temporarily incomplete.
|
||||
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: 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
|
||||
})
|
||||
const evidenceItems = mapPipelineEvidence(searchResult.evidence, allContacts)
|
||||
const collectionItems = mapPipelineEvidence(
|
||||
searchResult.evidenceCollection || searchResult.evidence,
|
||||
allContacts
|
||||
)
|
||||
setSearchTrace(mapSearchResultToTrace(searchResult, evidenceItems.length))
|
||||
setAgentTrace(searchResult.agent.trace)
|
||||
setEvidence(evidenceItems)
|
||||
setEvidenceCollection(collectionItems)
|
||||
setVisibleEvidenceCount(Math.min(EVIDENCE_PAGE_SIZE, collectionItems.length))
|
||||
setSenderNames(
|
||||
Object.fromEntries(
|
||||
evidenceItems
|
||||
.filter(({ message }) => Boolean(message.senderId && message.name))
|
||||
.map(({ message }) => [message.senderId as string, message.name as string])
|
||||
)
|
||||
)
|
||||
const nextSenderNames = mapEvidenceSenderNames(evidenceItems)
|
||||
setSenderNames(nextSenderNames)
|
||||
setMessageCount(searchResult.knowledge.totalMessages)
|
||||
if (searchResult.status === 'no_evidence') {
|
||||
setAnalysisError(`${RANGE_LABELS[effectiveRange]}内没有找到与问题相关的聊天消息。`)
|
||||
@@ -651,26 +456,14 @@ export function AISearchWorkspace({
|
||||
setResultQuery(normalizedQuery)
|
||||
setAnswer(searchResult.answer)
|
||||
rememberQuery(normalizedQuery)
|
||||
const cacheRecord: AISearchCacheRecord = {
|
||||
version: 3,
|
||||
persistSearchResult({
|
||||
key: cacheKey,
|
||||
createdAt: currentTimestamp(),
|
||||
answer: searchResult.answer,
|
||||
evidence: evidenceItems.map(compactCacheItem),
|
||||
evidenceCollection: collectionItems.map(compactCacheItem),
|
||||
senderNames: Object.fromEntries(
|
||||
evidenceItems
|
||||
.filter(({ message }) => Boolean(message.senderId && message.name))
|
||||
.map(({ message }) => [message.senderId as string, message.name as string])
|
||||
),
|
||||
evidence: evidenceItems,
|
||||
evidenceCollection: collectionItems,
|
||||
senderNames: nextSenderNames,
|
||||
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')
|
||||
} catch (error) {
|
||||
if (requestId && searchRequestIdRef.current !== requestId) return
|
||||
@@ -690,26 +483,12 @@ export function AISearchWorkspace({
|
||||
}
|
||||
|
||||
const startNewQuestion = (): void => {
|
||||
bypassCacheRef.current = false
|
||||
clearCacheBypass()
|
||||
setQuery('')
|
||||
setResultQuery('')
|
||||
setStage('idle')
|
||||
setAnswer('')
|
||||
setEvidence([])
|
||||
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.
|
||||
}
|
||||
resetSearchResult()
|
||||
clearActiveResult()
|
||||
composerRef.current?.focus()
|
||||
}
|
||||
|
||||
@@ -1012,14 +791,18 @@ export function AISearchWorkspace({
|
||||
{searchTrace?.retrievedEvidence || 0} 条相关消息 → {evidence.length} 条 Evidence →
|
||||
已生成回答{cachedAt ? ' · 已使用缓存' : ''}
|
||||
</p>
|
||||
{searchTrace && (
|
||||
{searchTrace &&
|
||||
(() => {
|
||||
const overview = formatSearchTraceOverview(searchTrace)
|
||||
return (
|
||||
<div className="ai-search-trace" aria-label="本次检索追踪">
|
||||
<span>总耗时 {formatDuration(searchTrace.timings.totalMs)}</span>
|
||||
<span>本地检索 {formatDuration(searchTrace.timings.knowledgeSearchMs)}</span>
|
||||
<span>AI {formatDuration(searchTrace.timings.aiGenerationMs)}</span>
|
||||
<span>上下文 {searchTrace.contextEvidence} 条</span>
|
||||
<span>总耗时 {overview.totalDuration}</span>
|
||||
<span>本地检索 {overview.knowledgeDuration}</span>
|
||||
<span>AI {overview.aiDuration}</span>
|
||||
<span>上下文 {overview.contextEvidence}</span>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
{renderSearchDetails()}
|
||||
</div>
|
||||
<div className="ai-search-result-actions">
|
||||
@@ -1032,7 +815,7 @@ export function AISearchWorkspace({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
bypassCacheRef.current = true
|
||||
skipNextCache()
|
||||
void runAnalysis()
|
||||
}}
|
||||
title="跳过缓存并重新读取聊天记录"
|
||||
@@ -1087,7 +870,7 @@ export function AISearchWorkspace({
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
bypassCacheRef.current = true
|
||||
skipNextCache()
|
||||
void runAnalysis(undefined, {
|
||||
range: expandToAll ? 'all' : '30d',
|
||||
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 { 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'
|
||||
|
||||
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 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 {
|
||||
/** Program-owned Final Evidence ID. Cached legacy records may omit it. */
|
||||
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