feat: 添加知识库检索功能和改进模型路由错误处理

- 在ChatRequest中添加Citation事件类型,支持引用来源展示
- 实现聊天上下文准备服务,构建带源元数据的受限聊天上下文
- 添加ThreadedProcess类以支持Windows平台的子进程操作
- 改进检索引擎中的错误处理和向量搜索逻辑
- 实现严格的嵌入模型验证和索引重建机制
- 添加前端聊天界面的知识库检索开关
- 实现搜索历史记录功能和错误降级处理
- 更新模型路由设置提示信息以反映索引重建需求
This commit is contained in:
2026-09-04 13:02:08 +08:00
parent 8c644d0aae
commit 6eb97bf9ab
20 changed files with 448 additions and 26 deletions
+2 -1
View File
@@ -66,7 +66,8 @@ async function openCitation(citation: Citation) {
<option v-for="provider in providerStore.enabledProviders" :key="provider.provider_id" :value="provider.provider_id">{{ provider.name }}</option>
</select></div>
<div class="field compact"><label>模型 ID</label><input v-model="chatStore.selectedModel" class="input" list="chat-models" placeholder="填写模型 ID" /><datalist id="chat-models"><option v-for="model in availableModels" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></div>
<span class="subtle">知识库问答与技能请使用智能体普通聊天尚未接入这些能力</span>
<label class="rag-toggle"><input v-model="chatStore.useRag" type="checkbox" :disabled="chatStore.isStreaming" />检索知识库</label>
<span class="subtle">开启后将相关笔记片段发送给所选模型并显示来源技能调用请使用智能体</span>
</header>
<div v-if="loadError || providerStore.error" class="error-banner chat-error">{{ loadError || providerStore.error }}</div>
<main class="message-timeline">
@@ -46,6 +46,12 @@ async function openResult(result: SearchResult) {
</div>
</form>
<div v-if="searchStore.error" class="error-banner">{{ searchStore.error }}</div>
<div v-if="searchStore.historyError" class="notice-banner">{{ searchStore.historyError }}</div>
<div v-if="searchStore.recentQueries.length" class="search-history">
<span class="subtle">最近搜索保存在当前浏览器</span>
<button v-for="item in searchStore.recentQueries" :key="item" class="button-secondary" @click="searchStore.query = item; submitSearch()">{{ item }}</button>
<button class="button-secondary" @click="searchStore.clearHistory">清空记录</button>
</div>
<div v-if="searchStore.vectorUnavailable" class="notice-banner">向量索引不可用已保留全文检索能力</div>
<div v-if="searchStore.results.length" class="results-header">
<span>找到 {{ searchStore.total }} 条结果</span><span class="badge info">{{ searchStore.mode }}</span>
@@ -69,6 +75,7 @@ async function openResult(result: SearchResult) {
.search-page > * { width: min(100%, 1040px); margin-inline: auto; }
.search-form { display: grid; grid-template-columns: 1fr auto; gap: var(--space-md); margin-bottom: var(--space-lg); }
.search-input { height: 44px; font-size: var(--font-size-lg); }
.search-history { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin-bottom: var(--space-md); }
.advanced { grid-column: 1 / -1; }
.results-header, .result-title, .result-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
.results-header { margin: var(--space-xl) 0 var(--space-md); color: var(--color-text-secondary); }
@@ -45,7 +45,7 @@ describe('ModelRoutingSettings', () => {
expect(wrapper.text()).toContain('本地采用 Qwen3-ASR')
expect(wrapper.text()).toContain('本地采用 ERes2NetV2')
expect(wrapper.text()).toContain('重建全部')
expect(wrapper.text()).toContain('重建完成前继续使用本地检索')
expect(wrapper.text()).toContain('重建完成前可使用全文检索')
expect(wrapper.text()).toContain('不是 OpenAI 标准接口')
for (const id of ['responses', 'anthropic', 'ollama', 'disabled']) expect(wrapper.get(`option[value="${id}"]`).attributes()).toHaveProperty('disabled')
expect(wrapper.get('option[value="p1"]').attributes()).not.toHaveProperty('disabled')
@@ -116,7 +116,7 @@ async function save() {
<fieldset :disabled="loading || saving || conflict">
<article v-for="capability in capabilities" :key="capability.id" class="routing-card" :data-capability="capability.id">
<h3>{{ capability.name }}</h3>
<p v-if="capability.id === 'embedding'" class="embedding-notice">更换模型或接口后请重建全部索引重建完成前继续使用本地检索</p>
<p v-if="capability.id === 'embedding'" class="embedding-notice">保存配置或更换模型接口后请重建全部索引配置成功不代表已有笔记的向量索引已更新重建完成前可使用全文检索混合检索会回退到全文检索</p>
<div class="protocols" aria-label="协议可用性">
<span v-for="protocol in protocols" :key="protocol.id" class="badge" :class="{ 'protocol-unavailable': !['openai_chat', 'openai_compatible'].includes(protocol.id) }">{{ protocol.label }}{{ ['openai_chat', 'openai_compatible'].includes(protocol.id) ? ' · 可用' : ' · 不可用' }}</span>
</div>
+3
View File
@@ -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')
+1 -1
View File
@@ -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('')
+40
View File
@@ -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'])
})
+31 -6
View File
@@ -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,