diff --git a/src/renderer/src/components/search/AISearchWorkspace.tsx b/src/renderer/src/components/search/AISearchWorkspace.tsx index 9a30d43..5cff5b0 100644 --- a/src/renderer/src/components/search/AISearchWorkspace.tsx +++ b/src/renderer/src/components/search/AISearchWorkspace.tsx @@ -9,7 +9,6 @@ import type { import type { AISearchWorkspaceProps, - EvidenceItem, SearchProgressByStage, SearchRange, SearchScope, @@ -42,8 +41,7 @@ import { createSearchResultResetState } from './searchState' import { useSearchHistory } from './hooks/useSearchHistory' import { useKnowledgeStatus } from './hooks/useKnowledgeStatus' import { useExternalProviderConsent } from './hooks/useExternalProviderConsent' - -const EVIDENCE_PAGE_SIZE = 8 +import { EVIDENCE_PAGE_SIZE, useEvidenceCollection } from './hooks/useEvidenceCollection' export function AISearchWorkspace({ contacts, @@ -64,10 +62,6 @@ export function AISearchWorkspace({ const [resultQuery, setResultQuery] = useState('') const [stage, setStage] = useState('idle') const [answer, setAnswer] = useState('') - const [evidence, setEvidence] = useState([]) - const [evidenceCollection, setEvidenceCollection] = useState([]) - const [visibleEvidenceCount, setVisibleEvidenceCount] = useState(0) - const [selectedEvidence, setSelectedEvidence] = useState(0) const [analysisError, setAnalysisError] = useState('') const [messageCount, setMessageCount] = useState(0) const [senderNames, setSenderNames] = useState>({}) @@ -83,12 +77,24 @@ export function AISearchWorkspace({ const [appLogPath, setAppLogPath] = useState('') const searchRequestIdRef = useRef('') const composerRef = useRef(null) - const evidenceCardRefs = useRef(new Map()) - const [evidenceFlash, setEvidenceFlash] = useState({ index: -1, nonce: 0 }) - const visibleEvidence = useMemo( - () => evidenceCollection.slice(0, visibleEvidenceCount), - [evidenceCollection, visibleEvidenceCount] - ) + const { + evidence, + setEvidence, + evidenceCollection, + setEvidenceCollection, + setVisibleEvidenceCount, + selectedEvidence, + setSelectedEvidence, + visibleEvidence, + hasMoreEvidence, + evidenceFlash, + setEvidenceResult, + clearEvidenceCollection, + loadMoreEvidence, + focusEvidence, + jumpToEvidence, + setEvidenceCardRef + } = useEvidenceCollection({ onOpenEvidence }) const { history, @@ -149,10 +155,7 @@ export function AISearchWorkspace({ const reset = createSearchResultResetState() setAnalysisError(reset.analysisError) setAnswer(reset.answer) - setEvidence(reset.evidence) - setEvidenceCollection(reset.evidenceCollection) - setVisibleEvidenceCount(reset.visibleEvidenceCount) - setSelectedEvidence(reset.selectedEvidence) + clearEvidenceCollection() setCachedAt(reset.cachedAt) setSearchTrace(reset.searchTrace) setSearchProgress(reset.searchProgress) @@ -160,21 +163,6 @@ export function AISearchWorkspace({ 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)) - setSelectedEvidence(index) - setEvidenceFlash((current) => ({ index, nonce: current.nonce + 1 })) - } - - React.useEffect(() => { - if (evidenceFlash.index < 0) return - evidenceCardRefs.current.get(evidenceFlash.index)?.scrollIntoView({ - behavior: 'smooth', - block: 'nearest' - }) - }, [evidenceFlash]) - React.useEffect(() => { void Promise.all([window.api.getSettings(), window.api.getAppLogPath()]).then( ([settingsResult, logPath]) => { @@ -355,9 +343,7 @@ export function AISearchWorkspace({ ) setSearchTrace(mapSearchResultToTrace(searchResult, evidenceItems.length)) setAgentTrace(searchResult.agent.trace) - setEvidence(evidenceItems) - setEvidenceCollection(collectionItems) - setVisibleEvidenceCount(Math.min(EVIDENCE_PAGE_SIZE, collectionItems.length)) + setEvidenceResult(evidenceItems, collectionItems) const nextSenderNames = mapEvidenceSenderNames(evidenceItems) setSenderNames(nextSenderNames) setMessageCount(searchResult.knowledge.totalMessages) @@ -1187,8 +1173,7 @@ export function AISearchWorkspace({
{ - if (node) evidenceCardRefs.current.set(index, node) - else evidenceCardRefs.current.delete(index) + setEvidenceCardRef(index, node) }} className={`ai-search-evidence-card ${selectedEvidence === index ? 'active' : ''} ${evidenceFlash.index === index ? 'focus-flash' : ''}`} style={{ animationDelay: `${Math.min(index, 7) * 45}ms` }} @@ -1213,7 +1198,7 @@ export function AISearchWorkspace({ className="ai-search-evidence-link" onClick={(event) => { event.stopPropagation() - onOpenEvidence(item.contact, item.message.createTime) + jumpToEvidence(index) }} > 跳转到原聊天 ↗ @@ -1227,15 +1212,11 @@ export function AISearchWorkspace({ 分析完成后,这里会显示支持结论的原始消息。 )} - {visibleEvidence.length > 0 && visibleEvidence.length < evidenceCollection.length && ( + {hasMoreEvidence && ( diff --git a/src/renderer/src/components/search/hooks/useEvidenceCollection.ts b/src/renderer/src/components/search/hooks/useEvidenceCollection.ts new file mode 100644 index 0000000..6076a8a --- /dev/null +++ b/src/renderer/src/components/search/hooks/useEvidenceCollection.ts @@ -0,0 +1,109 @@ +import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import type { Contact } from '../../../../../shared/types' +import type { EvidenceItem } from '../searchTypes' + +export const EVIDENCE_PAGE_SIZE = 8 + +type UseEvidenceCollectionOptions = { + onOpenEvidence: (contact: Contact, createTime?: number) => void +} + +export function useEvidenceCollection({ onOpenEvidence }: UseEvidenceCollectionOptions): { + evidence: EvidenceItem[] + setEvidence: Dispatch> + evidenceCollection: EvidenceItem[] + setEvidenceCollection: Dispatch> + visibleEvidenceCount: number + setVisibleEvidenceCount: Dispatch> + selectedEvidence: number + setSelectedEvidence: Dispatch> + visibleEvidence: EvidenceItem[] + hasMoreEvidence: boolean + evidenceFlash: { index: number; nonce: number } + evidenceCardRefs: React.MutableRefObject> + setEvidenceResult: (summaryEvidence: EvidenceItem[], collection: EvidenceItem[]) => void + clearEvidenceCollection: () => void + loadMoreEvidence: () => void + focusEvidence: (index: number) => void + jumpToEvidence: (index: number) => void + setEvidenceCardRef: (index: number, node: HTMLElement | null) => void +} { + const [evidence, setEvidence] = useState([]) + const [evidenceCollection, setEvidenceCollection] = useState([]) + const [visibleEvidenceCount, setVisibleEvidenceCount] = useState(0) + const [selectedEvidence, setSelectedEvidence] = useState(0) + const [evidenceFlash, setEvidenceFlash] = useState({ index: -1, nonce: 0 }) + const evidenceCardRefs = useRef(new Map()) + const visibleEvidence = useMemo( + () => evidenceCollection.slice(0, visibleEvidenceCount), + [evidenceCollection, visibleEvidenceCount] + ) + const hasMoreEvidence = + visibleEvidence.length > 0 && visibleEvidence.length < evidenceCollection.length + + const setEvidenceResult = (summaryEvidence: EvidenceItem[], collection: EvidenceItem[]): void => { + setEvidence(summaryEvidence) + setEvidenceCollection(collection) + setVisibleEvidenceCount(Math.min(EVIDENCE_PAGE_SIZE, collection.length)) + } + + const clearEvidenceCollection = (): void => { + setEvidence([]) + setEvidenceCollection([]) + setVisibleEvidenceCount(0) + setSelectedEvidence(0) + } + + const loadMoreEvidence = (): void => { + setVisibleEvidenceCount((current) => + Math.min(current + EVIDENCE_PAGE_SIZE, evidenceCollection.length) + ) + } + + const focusEvidence = (index: number): void => { + if (!Number.isInteger(index) || index < 0 || index >= evidenceCollection.length) return + setVisibleEvidenceCount((current) => Math.max(current, index + 1)) + setSelectedEvidence(index) + setEvidenceFlash((current) => ({ index, nonce: current.nonce + 1 })) + } + + const jumpToEvidence = (index: number): void => { + const item = evidenceCollection[index] + if (!item) return + onOpenEvidence(item.contact, item.message.createTime) + } + + const setEvidenceCardRef = (index: number, node: HTMLElement | null): void => { + if (node) evidenceCardRefs.current.set(index, node) + else evidenceCardRefs.current.delete(index) + } + + useEffect(() => { + if (evidenceFlash.index < 0) return + evidenceCardRefs.current.get(evidenceFlash.index)?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest' + }) + }, [evidenceFlash]) + + return { + evidence, + setEvidence, + evidenceCollection, + setEvidenceCollection, + visibleEvidenceCount, + setVisibleEvidenceCount, + selectedEvidence, + setSelectedEvidence, + visibleEvidence, + hasMoreEvidence, + evidenceFlash, + evidenceCardRefs, + setEvidenceResult, + clearEvidenceCollection, + loadMoreEvidence, + focusEvidence, + jumpToEvidence, + setEvidenceCardRef + } +} diff --git a/tests/component/ai-search-evidence-hook.test.tsx b/tests/component/ai-search-evidence-hook.test.tsx new file mode 100644 index 0000000..c9a35eb --- /dev/null +++ b/tests/component/ai-search-evidence-hook.test.tsx @@ -0,0 +1,229 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useEvidenceCollection } from '../../src/renderer/src/components/search/hooks/useEvidenceCollection' +import type { EvidenceItem } from '../../src/renderer/src/components/search/searchTypes' +import { + aiSearchContact, + makeCacheRecord, + makePipelineEvidence +} from './support/ai-search-fixtures' + +const onOpenEvidence = vi.fn() + +const makeEvidence = (count: number, offset = 0): EvidenceItem[] => + Array.from({ length: count }, (_, index) => { + const item = makePipelineEvidence(index + offset + 1) + return { + evidenceId: item.id, + contact: aiSearchContact, + message: { + id: item.messageId, + from: item.senderId, + type: '检索消息', + datetime: new Date(item.timestamp).toLocaleString('zh-CN', { hour12: false }), + content: item.text, + isSender: false, + name: item.sender, + senderId: item.senderId, + createTime: Math.floor(item.timestamp / 1000) + } + } + }) + +const useEvidenceHarness = () => useEvidenceCollection({ onOpenEvidence }) + +beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(window, 'api', { + configurable: true, + value: { runAiSearch: vi.fn() } + }) +}) + +describe('useEvidenceCollection', () => { + it('starts with zero Summary Evidence and zero Collection Evidence', () => { + const { result } = renderHook(() => useEvidenceHarness()) + + expect(result.current.evidence).toEqual([]) + expect(result.current.evidenceCollection).toEqual([]) + expect(result.current.visibleEvidence).toEqual([]) + expect(result.current.hasMoreEvidence).toBe(false) + }) + + it('shows one Evidence item without rendering a load-more affordance', () => { + const { result } = renderHook(() => useEvidenceHarness()) + const summary = makeEvidence(1) + + act(() => result.current.setEvidenceResult(summary, summary)) + + expect(result.current.evidence).toHaveLength(1) + expect(result.current.visibleEvidence).toHaveLength(1) + expect(result.current.hasMoreEvidence).toBe(false) + }) + + it('shows all eight Summary Evidence items on the first page', () => { + const { result } = renderHook(() => useEvidenceHarness()) + const summary = makeEvidence(8) + + act(() => result.current.setEvidenceResult(summary, summary)) + + expect(result.current.evidence).toHaveLength(8) + expect(result.current.visibleEvidence).toHaveLength(8) + expect(result.current.hasMoreEvidence).toBe(false) + }) + + it('keeps Summary Evidence capped at eight while Collection contains more items', () => { + const { result } = renderHook(() => useEvidenceHarness()) + const summary = makeEvidence(8) + const collection = makeEvidence(16) + + act(() => result.current.setEvidenceResult(summary, collection)) + + expect(result.current.evidence).toHaveLength(8) + expect(result.current.evidenceCollection).toHaveLength(16) + expect(result.current.visibleEvidence).toHaveLength(8) + expect(result.current.hasMoreEvidence).toBe(true) + }) + + it('loads more Collection Evidence in pages without invoking Search', () => { + const { result } = renderHook(() => useEvidenceHarness()) + const summary = makeEvidence(8) + const collection = makeEvidence(16) + act(() => result.current.setEvidenceResult(summary, collection)) + + act(() => result.current.loadMoreEvidence()) + + expect(result.current.visibleEvidence).toHaveLength(16) + expect(result.current.hasMoreEvidence).toBe(false) + expect(window.api.runAiSearch).not.toHaveBeenCalled() + }) + + it('does not change Summary Evidence when loading more Collection Evidence', () => { + const { result } = renderHook(() => useEvidenceHarness()) + const summary = makeEvidence(8) + const collection = makeEvidence(17) + act(() => result.current.setEvidenceResult(summary, collection)) + const summaryIds = result.current.evidence.map((item) => item.evidenceId) + + act(() => result.current.loadMoreEvidence()) + + expect(result.current.evidence.map((item) => item.evidenceId)).toEqual(summaryIds) + expect(result.current.evidence).toHaveLength(8) + }) + + it('preserves stable E1…En identifiers and ordering across pagination', () => { + const { result } = renderHook(() => useEvidenceHarness()) + const collection = makeEvidence(17) + act(() => result.current.setEvidenceResult(collection.slice(0, 8), collection)) + + const firstPageIds = result.current.visibleEvidence.map((item) => item.evidenceId) + act(() => result.current.loadMoreEvidence()) + const allIds = result.current.visibleEvidence.map((item) => item.evidenceId) + + expect(firstPageIds).toEqual(['E1', 'E2', 'E3', 'E4', 'E5', 'E6', 'E7', 'E8']) + expect(allIds).toEqual([ + 'E1', + 'E2', + 'E3', + 'E4', + 'E5', + 'E6', + 'E7', + 'E8', + 'E9', + 'E10', + 'E11', + 'E12', + 'E13', + 'E14', + 'E15', + 'E16' + ]) + }) + + it('selects and highlights an Evidence item while expanding visibility when needed', () => { + const { result } = renderHook(() => useEvidenceHarness()) + const collection = makeEvidence(16) + act(() => result.current.setEvidenceResult(collection.slice(0, 8), collection)) + + act(() => result.current.focusEvidence(10)) + + expect(result.current.selectedEvidence).toBe(10) + expect(result.current.visibleEvidence).toHaveLength(11) + expect(result.current.evidenceFlash.index).toBe(10) + expect(result.current.evidenceFlash.nonce).toBe(1) + }) + + it('scrolls the highlighted Evidence card into view', () => { + const { result } = renderHook(() => useEvidenceHarness()) + const scrollIntoView = vi.fn() + const card = { scrollIntoView } as unknown as HTMLElement + const collection = makeEvidence(1) + act(() => result.current.setEvidenceResult(collection, collection)) + act(() => { + result.current.setEvidenceCardRef(0, card) + result.current.focusEvidence(0) + }) + + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'nearest' }) + }) + + it('passes the selected Evidence contact and timestamp to the jump callback', () => { + const { result } = renderHook(() => useEvidenceHarness()) + const collection = makeEvidence(1) + act(() => result.current.setEvidenceResult(collection, collection)) + + act(() => result.current.jumpToEvidence(0)) + + expect(onOpenEvidence).toHaveBeenCalledWith(aiSearchContact, collection[0].message.createTime) + }) + + it('clears the previous request Evidence and selection before the next result is applied', () => { + const { result } = renderHook(() => useEvidenceHarness()) + const first = makeEvidence(1) + const second = makeEvidence(1, 1) + act(() => { + result.current.setEvidenceResult(first, first) + result.current.focusEvidence(0) + result.current.clearEvidenceCollection() + }) + + expect(result.current.evidence).toEqual([]) + expect(result.current.evidenceCollection).toEqual([]) + expect(result.current.selectedEvidence).toBe(0) + expect(result.current.visibleEvidence).toEqual([]) + + act(() => result.current.setEvidenceResult(second, second)) + expect(result.current.evidence.map((item) => item.evidenceId)).toEqual(['E2']) + expect(result.current.evidence).not.toContain(first[0]) + }) + + it('accepts legacy cache normalization where missing evidenceCollection falls back to evidence', () => { + const { result } = renderHook(() => useEvidenceHarness()) + const cached = makeCacheRecord({ + query: 'legacy evidence', + evidence: [makePipelineEvidence(1)] + }) + const legacyEvidence = cached.evidence as EvidenceItem[] + + act(() => result.current.setEvidenceResult(legacyEvidence, legacyEvidence)) + + expect(result.current.evidenceCollection).toBe(result.current.evidence) + expect(result.current.visibleEvidence).toHaveLength(1) + }) + + it('replaces, rather than merges, Collection Evidence between request boundaries', () => { + const { result } = renderHook(() => useEvidenceHarness()) + const first = makeEvidence(9) + const second = makeEvidence(2, 20) + + act(() => result.current.setEvidenceResult(first.slice(0, 8), first)) + act(() => { + result.current.clearEvidenceCollection() + result.current.setEvidenceResult(second, second) + }) + + expect(result.current.evidenceCollection.map((item) => item.evidenceId)).toEqual(['E21', 'E22']) + expect(result.current.evidenceCollection).not.toContain(first[8]) + }) +})