feat: 添加知识库检索功能和改进模型路由错误处理
- 在ChatRequest中添加Citation事件类型,支持引用来源展示 - 实现聊天上下文准备服务,构建带源元数据的受限聊天上下文 - 添加ThreadedProcess类以支持Windows平台的子进程操作 - 改进检索引擎中的错误处理和向量搜索逻辑 - 实现严格的嵌入模型验证和索引重建机制 - 添加前端聊天界面的知识库检索开关 - 实现搜索历史记录功能和错误降级处理 - 更新模型路由设置提示信息以反映索引重建需求
This commit is contained in:
@@ -16,6 +16,9 @@ it('sends real user history, applies streaming changes, and restores it when swi
|
||||
store.selectedModel = 'configured-model'
|
||||
await store.sendMessage('user input')
|
||||
const [request, handlers] = vi.mocked(streamChat).mock.calls[0]!
|
||||
expect(request.use_rag).toBe(true)
|
||||
handlers.onEvent?.({ event: 'Citation', sequence: 0, timestamp: '', data: { note_id: 'note', block_id: 'block', file_path: 'note.md', content: 'real evidence' } })
|
||||
expect(store.messages[1]?.citations?.[0]?.content).toBe('real evidence')
|
||||
expect(request.messages).toEqual([{ role: 'user', content: 'user input' }])
|
||||
handlers.onEvent?.({ event: 'TextDelta', sequence: 0, timestamp: '', data: { text: 'real response' } })
|
||||
expect(store.messages[1]?.content).toBe('real response')
|
||||
|
||||
@@ -10,7 +10,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const isStreaming = ref(false)
|
||||
const inputText = ref('')
|
||||
const useRag = ref(false)
|
||||
const useRag = ref(true)
|
||||
const selectedSkillId = ref<string | null>(null)
|
||||
const selectedProviderId = ref('')
|
||||
const selectedModel = ref('')
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useSearchStore } from './search'
|
||||
import { search } from '@/services/searchService'
|
||||
|
||||
vi.mock('@/services/searchService', () => ({ search: vi.fn() }))
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(search).mockReset().mockResolvedValue({ results: [], total: 0, mode: 'hybrid' })
|
||||
})
|
||||
|
||||
it('persists real queries across store recreation, reorders duplicates and clears history', async () => {
|
||||
const store = useSearchStore()
|
||||
expect(store.recentQueries).toEqual([])
|
||||
await store.doSearch({ query: ' first ' })
|
||||
await store.doSearch({ query: 'second' })
|
||||
await store.doSearch({ query: 'first' })
|
||||
setActivePinia(createPinia())
|
||||
const restored = useSearchStore()
|
||||
expect(restored.recentQueries).toEqual(['first', 'second'])
|
||||
restored.clearHistory()
|
||||
setActivePinia(createPinia())
|
||||
expect(useSearchStore().recentQueries).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores corrupt storage and does not let a stale request overwrite the latest search', async () => {
|
||||
localStorage.setItem('notes-agent.search-history.v1', '{bad')
|
||||
let finish!: (value: Awaited<ReturnType<typeof search>>) => void
|
||||
vi.mocked(search).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
const store = useSearchStore()
|
||||
const first = store.doSearch({ query: 'old' })
|
||||
await store.doSearch({ query: 'new' })
|
||||
finish({ results: [], total: 99, mode: 'hybrid' })
|
||||
await first
|
||||
expect(store.total).toBe(0)
|
||||
expect(store.query).toBe('new')
|
||||
expect(store.recentQueries).toEqual(['new', 'old'])
|
||||
})
|
||||
@@ -5,10 +5,19 @@ import * as searchService from '@/services/searchService'
|
||||
import { ApiErrorClass } from '@/services/apiClient'
|
||||
|
||||
const VECTOR_ERROR_CODES = new Set([
|
||||
'SEMANTIC_INDEX_UNAVAILABLE',
|
||||
'VECTOR_UNAVAILABLE', 'EMBEDDING_UNAVAILABLE', 'INDEX_UNAVAILABLE',
|
||||
'MODEL_NOT_FOUND', 'MODEL_CAPABILITY_MISMATCH', 'PROVIDER_UNAVAILABLE',
|
||||
])
|
||||
|
||||
const HISTORY_KEY = 'notes-agent.search-history.v1'
|
||||
function readHistory(): string[] {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(HISTORY_KEY) ?? '[]')
|
||||
return Array.isArray(value) ? [...new Set(value.filter((item): item is string => typeof item === 'string').map(item => item.trim()).filter(Boolean))].slice(0, 10) : []
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
export const useSearchStore = defineStore('search', () => {
|
||||
const query = ref('')
|
||||
const mode = ref<'fts' | 'vector' | 'hybrid'>('hybrid')
|
||||
@@ -16,11 +25,23 @@ export const useSearchStore = defineStore('search', () => {
|
||||
const total = ref(0)
|
||||
const isSearching = ref(false)
|
||||
const selectedIndex = ref(0)
|
||||
const recentQueries = ref<string[]>(['红黑树', '死锁', 'TCP三次握手'])
|
||||
const recentQueries = ref<string[]>(readHistory())
|
||||
const historyError = ref('')
|
||||
let searchVersion = 0
|
||||
function persistHistory() {
|
||||
try { localStorage.setItem(HISTORY_KEY, JSON.stringify(recentQueries.value)); historyError.value = '' }
|
||||
catch { historyError.value = '浏览器无法保存搜索记录,本次记录仅保留到页面关闭。' }
|
||||
}
|
||||
function clearHistory() { recentQueries.value = []; persistHistory() }
|
||||
const error = ref<string | null>(null)
|
||||
const vectorUnavailable = ref(false)
|
||||
|
||||
async function doSearch(request: SearchRequest) {
|
||||
request = { ...request, query: request.query.trim() }
|
||||
if (!request.query) return
|
||||
const version = ++searchVersion
|
||||
recentQueries.value = [request.query, ...recentQueries.value.filter(item => item !== request.query)].slice(0, 10)
|
||||
persistHistory()
|
||||
query.value = request.query
|
||||
mode.value = request.mode || 'hybrid'
|
||||
isSearching.value = true
|
||||
@@ -29,20 +50,24 @@ export const useSearchStore = defineStore('search', () => {
|
||||
|
||||
try {
|
||||
const resp = await searchService.search(request)
|
||||
if (version !== searchVersion) return
|
||||
results.value = resp.results
|
||||
total.value = resp.total
|
||||
selectedIndex.value = 0
|
||||
} catch (reason) {
|
||||
if (version !== searchVersion) return
|
||||
const canFallback = mode.value !== 'fts' && reason instanceof ApiErrorClass && VECTOR_ERROR_CODES.has(reason.code)
|
||||
if (canFallback) {
|
||||
try {
|
||||
const fallback = await searchService.search({ ...request, mode: 'fts' })
|
||||
if (version !== searchVersion) return
|
||||
results.value = fallback.results
|
||||
total.value = fallback.total
|
||||
mode.value = 'fts'
|
||||
vectorUnavailable.value = true
|
||||
selectedIndex.value = 0
|
||||
} catch (fallbackError) {
|
||||
if (version !== searchVersion) return
|
||||
error.value = fallbackError instanceof Error ? fallbackError.message : '全文检索降级失败'
|
||||
results.value = []
|
||||
total.value = 0
|
||||
@@ -53,16 +78,14 @@ export const useSearchStore = defineStore('search', () => {
|
||||
total.value = 0
|
||||
}
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
if (version === searchVersion) isSearching.value = false
|
||||
}
|
||||
|
||||
if (request.query && !recentQueries.value.includes(request.query)) {
|
||||
recentQueries.value.unshift(request.query)
|
||||
if (recentQueries.value.length > 10) recentQueries.value.pop()
|
||||
}
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
searchVersion++
|
||||
isSearching.value = false
|
||||
results.value = []
|
||||
query.value = ''
|
||||
total.value = 0
|
||||
@@ -90,6 +113,8 @@ export const useSearchStore = defineStore('search', () => {
|
||||
isSearching,
|
||||
selectedIndex,
|
||||
recentQueries,
|
||||
historyError,
|
||||
clearHistory,
|
||||
error,
|
||||
vectorUnavailable,
|
||||
doSearch,
|
||||
|
||||
Reference in New Issue
Block a user