From 45abfddc5fa6d078f057929ab6f934a439f5b8c9 Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Tue, 18 Aug 2026 17:07:07 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=20AI=20=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E7=9F=A5=E8=AF=86=E5=BA=93=E4=B8=8E=E6=8E=88=E6=9D=83?= =?UTF-8?q?=20Hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/search/AISearchWorkspace.tsx | 103 +++---------- .../hooks/useExternalProviderConsent.ts | 61 ++++++++ .../search/hooks/useKnowledgeStatus.ts | 68 +++++++++ .../ai-search-knowledge-status-hook.test.tsx | 136 ++++++++++++++++++ .../ai-search-provider-consent-hook.test.tsx | 135 +++++++++++++++++ 5 files changed, 416 insertions(+), 87 deletions(-) create mode 100644 src/renderer/src/components/search/hooks/useExternalProviderConsent.ts create mode 100644 src/renderer/src/components/search/hooks/useKnowledgeStatus.ts create mode 100644 tests/component/ai-search-knowledge-status-hook.test.tsx create mode 100644 tests/component/ai-search-provider-consent-hook.test.tsx diff --git a/src/renderer/src/components/search/AISearchWorkspace.tsx b/src/renderer/src/components/search/AISearchWorkspace.tsx index 1558fe0..9a30d43 100644 --- a/src/renderer/src/components/search/AISearchWorkspace.tsx +++ b/src/renderer/src/components/search/AISearchWorkspace.tsx @@ -16,7 +16,6 @@ import type { SearchStage, SearchTrace } from './searchTypes' -import type { KnowledgeRuntimeStatus } from '../../../../shared/knowledge' import { RANGE_LABELS, buildSearchCacheKey, @@ -41,11 +40,8 @@ import { } from './searchMappers' import { createSearchResultResetState } from './searchState' import { useSearchHistory } from './hooks/useSearchHistory' - -type ExternalProviderConsent = { - providerName: string - recipient: string -} +import { useKnowledgeStatus } from './hooks/useKnowledgeStatus' +import { useExternalProviderConsent } from './hooks/useExternalProviderConsent' const EVIDENCE_PAGE_SIZE = 8 @@ -76,8 +72,6 @@ export function AISearchWorkspace({ const [messageCount, setMessageCount] = useState(0) const [senderNames, setSenderNames] = useState>({}) const [cachedAt, setCachedAt] = useState(0) - const [knowledgeStatus, setKnowledgeStatus] = useState(null) - const [syncStarting, setSyncStarting] = useState(false) const [searchTrace, setSearchTrace] = useState(null) const [searchProgress, setSearchProgress] = useState({}) const [agentTrace, setAgentTrace] = useState([]) @@ -88,12 +82,8 @@ export function AISearchWorkspace({ const [debugEntries, setDebugEntries] = useState([]) const [appLogPath, setAppLogPath] = useState('') const searchRequestIdRef = useRef('') - const knowledgeSyncingRef = useRef(false) const composerRef = useRef(null) const evidenceCardRefs = useRef(new Map()) - const externalConsentResolverRef = useRef<((approved: boolean) => void) | null>(null) - const [externalProviderConsent, setExternalProviderConsent] = - useState(null) const [evidenceFlash, setEvidenceFlash] = useState({ index: -1, nonce: 0 }) const visibleEvidence = useMemo( () => evidenceCollection.slice(0, visibleEvidenceCount), @@ -141,6 +131,19 @@ export function AISearchWorkspace({ setHistoryOpen, onNotice }) + const { + knowledgeStatus, + syncStarting, + knowledgeSyncing, + knowledgeSyncingRef, + startKnowledgeSync + } = useKnowledgeStatus({ dbReady, onNotice }) + const { + externalProviderConsent, + requestExternalProviderConsent, + settleExternalProviderConsent, + clearExternalProviderConsent + } = useExternalProviderConsent() const resetSearchResult = (): void => { const reset = createSearchResultResetState() @@ -164,39 +167,6 @@ export function AISearchWorkspace({ setEvidenceFlash((current) => ({ index, nonce: current.nonce + 1 })) } - const settleExternalProviderConsent = (approved: boolean): void => { - const resolve = externalConsentResolverRef.current - externalConsentResolverRef.current = null - setExternalProviderConsent(null) - resolve?.(approved) - } - - const requestExternalProviderConsent = ( - providerName: string, - recipient: string - ): Promise => - new Promise((resolve) => { - externalConsentResolverRef.current = resolve - setExternalProviderConsent({ providerName, recipient }) - }) - - React.useEffect( - () => () => { - externalConsentResolverRef.current?.(false) - externalConsentResolverRef.current = null - }, - [] - ) - - React.useEffect(() => { - if (!externalProviderConsent) return - const onKeyDown = (event: KeyboardEvent): void => { - if (event.key === 'Escape') settleExternalProviderConsent(false) - } - window.addEventListener('keydown', onKeyDown) - return () => window.removeEventListener('keydown', onKeyDown) - }, [externalProviderConsent]) - React.useEffect(() => { if (evidenceFlash.index < 0) return evidenceCardRefs.current.get(evidenceFlash.index)?.scrollIntoView({ @@ -214,23 +184,6 @@ export function AISearchWorkspace({ ) }, []) - React.useEffect(() => { - let active = true - void window.api - .getKnowledgeStatus() - .then((status) => { - if (active) setKnowledgeStatus(status) - }) - .catch(() => undefined) - const unsubscribe = window.api.onKnowledgeStatus((status) => { - if (active) setKnowledgeStatus(status) - }) - return () => { - active = false - unsubscribe() - } - }, []) - React.useEffect( () => window.api.onAiSearchProgress((progress) => { @@ -274,31 +227,6 @@ export function AISearchWorkspace({ const modelLabel = aiModelConfig.configured ? `${aiModelConfig.providerName} · ${aiModelConfig.modelName}` : '尚未配置 AI 模型' - const knowledgeSyncing = - syncStarting || knowledgeStatus?.state === 'building' || knowledgeStatus?.state === 'syncing' - knowledgeSyncingRef.current = knowledgeSyncing - - const startKnowledgeSync = async (): Promise => { - if (!dbReady) { - onNotice('请先连接微信数据后再建立本地知识库') - return - } - setSyncStarting(true) - try { - const status = await window.api.startKnowledgeIndex() - setKnowledgeStatus(status) - onNotice( - status.state === 'syncing' - ? '已开始同步最新聊天记录' - : '已开始建立本地知识库,可继续使用软件' - ) - } catch (error) { - onNotice(error instanceof Error ? error.message : '启动知识库同步失败') - } finally { - setSyncStarting(false) - } - } - const ensureAiSearchDataConsent = async (requestId: string): Promise => { const status = await window.api.getAiSearchProviderStatus() if (!status.configured || !status.requiresConsent) return true @@ -318,6 +246,7 @@ export function AISearchWorkspace({ } const cancelAnalysis = async (): Promise => { + clearExternalProviderConsent() const requestId = searchRequestIdRef.current if (!requestId) return searchRequestIdRef.current = '' diff --git a/src/renderer/src/components/search/hooks/useExternalProviderConsent.ts b/src/renderer/src/components/search/hooks/useExternalProviderConsent.ts new file mode 100644 index 0000000..4384021 --- /dev/null +++ b/src/renderer/src/components/search/hooks/useExternalProviderConsent.ts @@ -0,0 +1,61 @@ +import { useEffect, useRef, useState } from 'react' + +export type ExternalProviderConsent = { + providerName: string + recipient: string +} + +export function useExternalProviderConsent(): { + externalProviderConsent: ExternalProviderConsent | null + requestExternalProviderConsent: (providerName: string, recipient: string) => Promise + settleExternalProviderConsent: (approved: boolean) => void + clearExternalProviderConsent: () => void +} { + const [externalProviderConsent, setExternalProviderConsent] = + useState(null) + const externalConsentResolverRef = useRef<((approved: boolean) => void) | null>(null) + + const settleExternalProviderConsent = (approved: boolean): void => { + const resolve = externalConsentResolverRef.current + externalConsentResolverRef.current = null + setExternalProviderConsent(null) + resolve?.(approved) + } + + const requestExternalProviderConsent = ( + providerName: string, + recipient: string + ): Promise => + new Promise((resolve) => { + externalConsentResolverRef.current = resolve + setExternalProviderConsent({ providerName, recipient }) + }) + + const clearExternalProviderConsent = (): void => { + settleExternalProviderConsent(false) + } + + useEffect( + () => () => { + externalConsentResolverRef.current?.(false) + externalConsentResolverRef.current = null + }, + [] + ) + + useEffect(() => { + if (!externalProviderConsent) return + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') settleExternalProviderConsent(false) + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [externalProviderConsent]) + + return { + externalProviderConsent, + requestExternalProviderConsent, + settleExternalProviderConsent, + clearExternalProviderConsent + } +} diff --git a/src/renderer/src/components/search/hooks/useKnowledgeStatus.ts b/src/renderer/src/components/search/hooks/useKnowledgeStatus.ts new file mode 100644 index 0000000..4ae2141 --- /dev/null +++ b/src/renderer/src/components/search/hooks/useKnowledgeStatus.ts @@ -0,0 +1,68 @@ +import { useEffect, useRef, useState } from 'react' +import type { KnowledgeRuntimeStatus } from '../../../../../shared/knowledge' + +type UseKnowledgeStatusOptions = { + dbReady: boolean + onNotice: (message: string) => void +} + +export function useKnowledgeStatus({ dbReady, onNotice }: UseKnowledgeStatusOptions): { + knowledgeStatus: KnowledgeRuntimeStatus | null + syncStarting: boolean + knowledgeSyncing: boolean + knowledgeSyncingRef: React.MutableRefObject + startKnowledgeSync: () => Promise +} { + const [knowledgeStatus, setKnowledgeStatus] = useState(null) + const [syncStarting, setSyncStarting] = useState(false) + const knowledgeSyncingRef = useRef(false) + const knowledgeSyncing = + syncStarting || knowledgeStatus?.state === 'building' || knowledgeStatus?.state === 'syncing' + knowledgeSyncingRef.current = knowledgeSyncing + + useEffect(() => { + let active = true + void window.api + .getKnowledgeStatus() + .then((status) => { + if (active) setKnowledgeStatus(status) + }) + .catch(() => undefined) + const unsubscribe = window.api.onKnowledgeStatus((status) => { + if (active) setKnowledgeStatus(status) + }) + return () => { + active = false + unsubscribe() + } + }, []) + + const startKnowledgeSync = async (): Promise => { + if (!dbReady) { + onNotice('请先连接微信数据后再建立本地知识库') + return + } + setSyncStarting(true) + try { + const status = await window.api.startKnowledgeIndex() + setKnowledgeStatus(status) + onNotice( + status.state === 'syncing' + ? '已开始同步最新聊天记录' + : '已开始建立本地知识库,可继续使用软件' + ) + } catch (error) { + onNotice(error instanceof Error ? error.message : '启动知识库同步失败') + } finally { + setSyncStarting(false) + } + } + + return { + knowledgeStatus, + syncStarting, + knowledgeSyncing, + knowledgeSyncingRef, + startKnowledgeSync + } +} diff --git a/tests/component/ai-search-knowledge-status-hook.test.tsx b/tests/component/ai-search-knowledge-status-hook.test.tsx new file mode 100644 index 0000000..01f8cec --- /dev/null +++ b/tests/component/ai-search-knowledge-status-hook.test.tsx @@ -0,0 +1,136 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useKnowledgeStatus } from '../../src/renderer/src/components/search/hooks/useKnowledgeStatus' +import type { KnowledgeRuntimeState, KnowledgeRuntimeStatus } from '../../src/shared/knowledge' + +const api = { + getKnowledgeStatus: vi.fn(), + onKnowledgeStatus: vi.fn(), + startKnowledgeIndex: vi.fn() +} + +const makeStatus = ( + state: KnowledgeRuntimeState, + overrides: Partial = {} +): KnowledgeRuntimeStatus => ({ + accountId: 'knowledge-account', + state, + indexedMessageCount: 20, + indexedChunkCount: 4, + sourceMessageCount: 20, + processedMessages: 20, + totalMessages: 20, + estimatedRemainingMs: null, + databaseBytes: 128, + walBytes: 64, + shmBytes: 32, + ...overrides +}) + +let knowledgeListener: ((status: KnowledgeRuntimeStatus) => void) | undefined +let unsubscribe: ReturnType +const onNotice = vi.fn() + +beforeEach(() => { + vi.clearAllMocks() + knowledgeListener = undefined + unsubscribe = vi.fn() + Object.defineProperty(window, 'api', { configurable: true, value: api }) + api.getKnowledgeStatus.mockResolvedValue(makeStatus('ready')) + api.onKnowledgeStatus.mockImplementation((listener) => { + knowledgeListener = listener + return unsubscribe + }) + api.startKnowledgeIndex.mockResolvedValue(makeStatus('syncing')) +}) + +describe('useKnowledgeStatus', () => { + it.each(['unavailable', 'building', 'syncing', 'ready', 'error'])( + 'loads the initial %s runtime state', + async (state) => { + api.getKnowledgeStatus.mockResolvedValue(makeStatus(state)) + + const { result } = renderHook(() => useKnowledgeStatus({ dbReady: true, onNotice })) + + await waitFor(() => expect(result.current.knowledgeStatus?.state).toBe(state)) + expect(result.current.knowledgeSyncing).toBe(state === 'building' || state === 'syncing') + expect(result.current.knowledgeSyncingRef.current).toBe(result.current.knowledgeSyncing) + } + ) + + it('keeps the renderer usable when the initial Knowledge Worker request fails', async () => { + api.getKnowledgeStatus.mockRejectedValue(new Error('Knowledge Worker unavailable')) + + const { result } = renderHook(() => useKnowledgeStatus({ dbReady: true, onNotice })) + + await waitFor(() => expect(api.getKnowledgeStatus).toHaveBeenCalledOnce()) + expect(result.current.knowledgeStatus).toBeNull() + expect(api.onKnowledgeStatus).toHaveBeenCalledOnce() + }) + + it('applies Worker status events and releases the subscription on unmount', async () => { + const { result, unmount } = renderHook(() => useKnowledgeStatus({ dbReady: true, onNotice })) + await waitFor(() => expect(result.current.knowledgeStatus?.state).toBe('ready')) + + act(() => knowledgeListener?.(makeStatus('building', { processedMessages: 3 }))) + expect(result.current.knowledgeStatus).toMatchObject({ + state: 'building', + processedMessages: 3 + }) + expect(result.current.knowledgeSyncing).toBe(true) + + act(() => knowledgeListener?.(makeStatus('error', { lastError: 'Worker crashed' }))) + expect(result.current.knowledgeStatus).toMatchObject({ + state: 'error', + lastError: 'Worker crashed' + }) + + unmount() + expect(unsubscribe).toHaveBeenCalledOnce() + }) + + it('does not start Index when the database is not ready', async () => { + const { result } = renderHook(() => useKnowledgeStatus({ dbReady: false, onNotice })) + + await act(() => result.current.startKnowledgeSync()) + + expect(api.startKnowledgeIndex).not.toHaveBeenCalled() + expect(onNotice).toHaveBeenCalledWith('请先连接微信数据后再建立本地知识库') + }) + + it('starts Index, exposes the pending state and applies the returned Worker status', async () => { + let resolveStart: ((status: KnowledgeRuntimeStatus) => void) | undefined + api.startKnowledgeIndex.mockReturnValue( + new Promise((resolve) => { + resolveStart = resolve + }) + ) + const { result } = renderHook(() => useKnowledgeStatus({ dbReady: true, onNotice })) + await waitFor(() => expect(result.current.knowledgeStatus?.state).toBe('ready')) + + let pending: Promise + act(() => { + pending = result.current.startKnowledgeSync() + }) + expect(result.current.syncStarting).toBe(true) + expect(result.current.knowledgeSyncing).toBe(true) + + await act(async () => { + resolveStart?.(makeStatus('syncing')) + await pending + }) + expect(result.current.syncStarting).toBe(false) + expect(result.current.knowledgeStatus?.state).toBe('syncing') + expect(onNotice).toHaveBeenCalledWith('已开始同步最新聊天记录') + }) + + it('reports an Index start failure and clears the pending state', async () => { + api.startKnowledgeIndex.mockRejectedValue(new Error('Worker 启动失败')) + const { result } = renderHook(() => useKnowledgeStatus({ dbReady: true, onNotice })) + + await act(() => result.current.startKnowledgeSync()) + + expect(result.current.syncStarting).toBe(false) + expect(onNotice).toHaveBeenCalledWith('Worker 启动失败') + }) +}) diff --git a/tests/component/ai-search-provider-consent-hook.test.tsx b/tests/component/ai-search-provider-consent-hook.test.tsx new file mode 100644 index 0000000..377ea24 --- /dev/null +++ b/tests/component/ai-search-provider-consent-hook.test.tsx @@ -0,0 +1,135 @@ +import { act, fireEvent, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useExternalProviderConsent } from '../../src/renderer/src/components/search/hooks/useExternalProviderConsent' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('useExternalProviderConsent', () => { + it('opens consent with the current provider display state', () => { + const { result } = renderHook(() => useExternalProviderConsent()) + + act(() => { + void result.current.requestExternalProviderConsent( + 'Remote Provider', + 'https://remote.example.test/v1' + ) + }) + + expect(result.current.externalProviderConsent).toEqual({ + providerName: 'Remote Provider', + recipient: 'https://remote.example.test/v1' + }) + }) + + it('resolves true and clears the resolver when the user confirms', async () => { + const { result } = renderHook(() => useExternalProviderConsent()) + let consent!: Promise + act(() => { + consent = result.current.requestExternalProviderConsent('Provider', 'recipient') + }) + + act(() => result.current.settleExternalProviderConsent(true)) + + await expect(consent).resolves.toBe(true) + expect(result.current.externalProviderConsent).toBeNull() + }) + + it('resolves false and clears the resolver when the user rejects', async () => { + const { result } = renderHook(() => useExternalProviderConsent()) + let consent!: Promise + act(() => { + consent = result.current.requestExternalProviderConsent('Provider', 'recipient') + }) + + act(() => result.current.settleExternalProviderConsent(false)) + + await expect(consent).resolves.toBe(false) + expect(result.current.externalProviderConsent).toBeNull() + }) + + it('treats Escape as rejection and removes the temporary dialog state', async () => { + const { result } = renderHook(() => useExternalProviderConsent()) + let consent!: Promise + act(() => { + consent = result.current.requestExternalProviderConsent('Provider', 'recipient') + }) + + fireEvent.keyDown(window, { key: 'Escape' }) + + await expect(consent).resolves.toBe(false) + expect(result.current.externalProviderConsent).toBeNull() + }) + + it('clears a pending resolver when Search is cancelled', async () => { + const { result } = renderHook(() => useExternalProviderConsent()) + let consent!: Promise + act(() => { + consent = result.current.requestExternalProviderConsent('Provider', 'recipient') + }) + + act(() => result.current.clearExternalProviderConsent()) + + await expect(consent).resolves.toBe(false) + expect(result.current.externalProviderConsent).toBeNull() + }) + + it('leaves no resolver after the confirmed Search path succeeds', async () => { + const { result } = renderHook(() => useExternalProviderConsent()) + let consent!: Promise + act(() => { + consent = result.current.requestExternalProviderConsent('Provider', 'recipient') + }) + act(() => result.current.settleExternalProviderConsent(true)) + + await expect(consent).resolves.toBe(true) + act(() => result.current.clearExternalProviderConsent()) + expect(result.current.externalProviderConsent).toBeNull() + }) + + it('leaves no resolver after the authorized Search path later fails', async () => { + const { result } = renderHook(() => useExternalProviderConsent()) + let consent!: Promise + act(() => { + consent = result.current.requestExternalProviderConsent('Provider', 'recipient') + }) + act(() => result.current.settleExternalProviderConsent(true)) + + await expect(consent).resolves.toBe(true) + expect(result.current.externalProviderConsent).toBeNull() + }) + + it('resolves a pending consent as false and releases listeners on unmount', async () => { + const removeEventListener = vi.spyOn(window, 'removeEventListener') + const { result, unmount } = renderHook(() => useExternalProviderConsent()) + let consent!: Promise + act(() => { + consent = result.current.requestExternalProviderConsent('Provider', 'recipient') + }) + + unmount() + + await expect(consent).resolves.toBe(false) + expect(removeEventListener).toHaveBeenCalledWith('keydown', expect.any(Function)) + }) + + it('resolves the current request exactly once', async () => { + const { result } = renderHook(() => useExternalProviderConsent()) + const resolved = vi.fn() + let consent!: Promise + act(() => { + consent = result.current.requestExternalProviderConsent('Provider', 'recipient') + void consent.then(resolved) + }) + + act(() => { + result.current.settleExternalProviderConsent(true) + result.current.settleExternalProviderConsent(false) + }) + + await expect(consent).resolves.toBe(true) + expect(resolved).toHaveBeenCalledOnce() + expect(resolved).toHaveBeenCalledWith(true) + }) +})