mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-21 21:47:00 +08:00
refactor: 拆分 AI 搜索知识库与授权 Hook
This commit is contained in:
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user