diff --git a/src/renderer/src/components/search/AISearchWorkspace.tsx b/src/renderer/src/components/search/AISearchWorkspace.tsx index 1d58126..1558fe0 100644 --- a/src/renderer/src/components/search/AISearchWorkspace.tsx +++ b/src/renderer/src/components/search/AISearchWorkspace.tsx @@ -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> +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(() => { - 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>({}) const [cachedAt, setCachedAt] = useState(0) const [knowledgeStatus, setKnowledgeStatus] = useState(null) @@ -148,7 +87,6 @@ export function AISearchWorkspace({ const [debugPanelOpen, setDebugPanelOpen] = useState(false) const [debugEntries, setDebugEntries] = useState([]) const [appLogPath, setAppLogPath] = useState('') - const bypassCacheRef = useRef(false) const searchRequestIdRef = useRef('') const knowledgeSyncingRef = useRef(false) const composerRef = useRef(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 => { 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 ? ' · 已使用缓存' : ''}

- {searchTrace && ( -
- 总耗时 {formatDuration(searchTrace.timings.totalMs)} - 本地检索 {formatDuration(searchTrace.timings.knowledgeSearchMs)} - AI {formatDuration(searchTrace.timings.aiGenerationMs)} - 上下文 {searchTrace.contextEvidence} 条 -
- )} + {searchTrace && + (() => { + const overview = formatSearchTraceOverview(searchTrace) + return ( +
+ 总耗时 {overview.totalDuration} + 本地检索 {overview.knowledgeDuration} + AI {overview.aiDuration} + 上下文 {overview.contextEvidence} +
+ ) + })()} {renderSearchDetails()}
@@ -1032,7 +815,7 @@ export function AISearchWorkspace({