refactor: 拆分 AI 搜索知识库与授权 Hook

This commit is contained in:
Wxw-Gu
2026-08-18 17:07:07 +08:00
parent 366e2622cc
commit 45abfddc5f
5 changed files with 416 additions and 87 deletions
@@ -16,7 +16,6 @@ import type {
SearchStage, SearchStage,
SearchTrace SearchTrace
} from './searchTypes' } from './searchTypes'
import type { KnowledgeRuntimeStatus } from '../../../../shared/knowledge'
import { import {
RANGE_LABELS, RANGE_LABELS,
buildSearchCacheKey, buildSearchCacheKey,
@@ -41,11 +40,8 @@ import {
} from './searchMappers' } from './searchMappers'
import { createSearchResultResetState } from './searchState' import { createSearchResultResetState } from './searchState'
import { useSearchHistory } from './hooks/useSearchHistory' import { useSearchHistory } from './hooks/useSearchHistory'
import { useKnowledgeStatus } from './hooks/useKnowledgeStatus'
type ExternalProviderConsent = { import { useExternalProviderConsent } from './hooks/useExternalProviderConsent'
providerName: string
recipient: string
}
const EVIDENCE_PAGE_SIZE = 8 const EVIDENCE_PAGE_SIZE = 8
@@ -76,8 +72,6 @@ export function AISearchWorkspace({
const [messageCount, setMessageCount] = useState(0) const [messageCount, setMessageCount] = useState(0)
const [senderNames, setSenderNames] = useState<Record<string, string>>({}) const [senderNames, setSenderNames] = useState<Record<string, string>>({})
const [cachedAt, setCachedAt] = useState(0) const [cachedAt, setCachedAt] = useState(0)
const [knowledgeStatus, setKnowledgeStatus] = useState<KnowledgeRuntimeStatus | null>(null)
const [syncStarting, setSyncStarting] = useState(false)
const [searchTrace, setSearchTrace] = useState<SearchTrace | null>(null) const [searchTrace, setSearchTrace] = useState<SearchTrace | null>(null)
const [searchProgress, setSearchProgress] = useState<SearchProgressByStage>({}) const [searchProgress, setSearchProgress] = useState<SearchProgressByStage>({})
const [agentTrace, setAgentTrace] = useState<AiSearchAgentRun['trace']>([]) const [agentTrace, setAgentTrace] = useState<AiSearchAgentRun['trace']>([])
@@ -88,12 +82,8 @@ export function AISearchWorkspace({
const [debugEntries, setDebugEntries] = useState<string[]>([]) const [debugEntries, setDebugEntries] = useState<string[]>([])
const [appLogPath, setAppLogPath] = useState('') const [appLogPath, setAppLogPath] = useState('')
const searchRequestIdRef = useRef('') const searchRequestIdRef = useRef('')
const knowledgeSyncingRef = useRef(false)
const composerRef = useRef<HTMLTextAreaElement>(null) const composerRef = useRef<HTMLTextAreaElement>(null)
const evidenceCardRefs = useRef(new Map<number, HTMLElement>()) const evidenceCardRefs = useRef(new Map<number, HTMLElement>())
const externalConsentResolverRef = useRef<((approved: boolean) => void) | null>(null)
const [externalProviderConsent, setExternalProviderConsent] =
useState<ExternalProviderConsent | null>(null)
const [evidenceFlash, setEvidenceFlash] = useState({ index: -1, nonce: 0 }) const [evidenceFlash, setEvidenceFlash] = useState({ index: -1, nonce: 0 })
const visibleEvidence = useMemo( const visibleEvidence = useMemo(
() => evidenceCollection.slice(0, visibleEvidenceCount), () => evidenceCollection.slice(0, visibleEvidenceCount),
@@ -141,6 +131,19 @@ export function AISearchWorkspace({
setHistoryOpen, setHistoryOpen,
onNotice onNotice
}) })
const {
knowledgeStatus,
syncStarting,
knowledgeSyncing,
knowledgeSyncingRef,
startKnowledgeSync
} = useKnowledgeStatus({ dbReady, onNotice })
const {
externalProviderConsent,
requestExternalProviderConsent,
settleExternalProviderConsent,
clearExternalProviderConsent
} = useExternalProviderConsent()
const resetSearchResult = (): void => { const resetSearchResult = (): void => {
const reset = createSearchResultResetState() const reset = createSearchResultResetState()
@@ -164,39 +167,6 @@ export function AISearchWorkspace({
setEvidenceFlash((current) => ({ index, nonce: current.nonce + 1 })) 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<boolean> =>
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(() => { React.useEffect(() => {
if (evidenceFlash.index < 0) return if (evidenceFlash.index < 0) return
evidenceCardRefs.current.get(evidenceFlash.index)?.scrollIntoView({ 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( React.useEffect(
() => () =>
window.api.onAiSearchProgress((progress) => { window.api.onAiSearchProgress((progress) => {
@@ -274,31 +227,6 @@ export function AISearchWorkspace({
const modelLabel = aiModelConfig.configured const modelLabel = aiModelConfig.configured
? `${aiModelConfig.providerName} · ${aiModelConfig.modelName}` ? `${aiModelConfig.providerName} · ${aiModelConfig.modelName}`
: '尚未配置 AI 模型' : '尚未配置 AI 模型'
const knowledgeSyncing =
syncStarting || knowledgeStatus?.state === 'building' || knowledgeStatus?.state === 'syncing'
knowledgeSyncingRef.current = knowledgeSyncing
const startKnowledgeSync = async (): Promise<void> => {
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<boolean> => { const ensureAiSearchDataConsent = async (requestId: string): Promise<boolean> => {
const status = await window.api.getAiSearchProviderStatus() const status = await window.api.getAiSearchProviderStatus()
if (!status.configured || !status.requiresConsent) return true if (!status.configured || !status.requiresConsent) return true
@@ -318,6 +246,7 @@ export function AISearchWorkspace({
} }
const cancelAnalysis = async (): Promise<void> => { const cancelAnalysis = async (): Promise<void> => {
clearExternalProviderConsent()
const requestId = searchRequestIdRef.current const requestId = searchRequestIdRef.current
if (!requestId) return if (!requestId) return
searchRequestIdRef.current = '' searchRequestIdRef.current = ''
@@ -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<boolean>
settleExternalProviderConsent: (approved: boolean) => void
clearExternalProviderConsent: () => void
} {
const [externalProviderConsent, setExternalProviderConsent] =
useState<ExternalProviderConsent | null>(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<boolean> =>
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
}
}
@@ -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<boolean>
startKnowledgeSync: () => Promise<void>
} {
const [knowledgeStatus, setKnowledgeStatus] = useState<KnowledgeRuntimeStatus | null>(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<void> => {
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
}
}
@@ -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> = {}
): 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<typeof vi.fn>
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<KnowledgeRuntimeState>(['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<KnowledgeRuntimeStatus>((resolve) => {
resolveStart = resolve
})
)
const { result } = renderHook(() => useKnowledgeStatus({ dbReady: true, onNotice }))
await waitFor(() => expect(result.current.knowledgeStatus?.state).toBe('ready'))
let pending: Promise<void>
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 启动失败')
})
})
@@ -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<boolean>
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<boolean>
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<boolean>
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<boolean>
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<boolean>
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<boolean>
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<boolean>
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<boolean>
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)
})
})