feat: 拆分代码

This commit is contained in:
Wxw-Gu
2026-08-18 16:49:25 +08:00
parent 3352936744
commit 366e2622cc
8 changed files with 1309 additions and 327 deletions
@@ -2,58 +2,45 @@ import React, { useMemo, useRef, useState } from 'react'
import * as Popover from '@radix-ui/react-popover'
import { 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 && (
<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>
</div>
)}
{searchTrace &&
(() => {
const overview = formatSearchTraceOverview(searchTrace)
return (
<div className="ai-search-trace" aria-label="本次检索追踪">
<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