- 在ChatRequest中添加Citation事件类型,支持引用来源展示 - 实现聊天上下文准备服务,构建带源元数据的受限聊天上下文 - 添加ThreadedProcess类以支持Windows平台的子进程操作 - 改进检索引擎中的错误处理和向量搜索逻辑 - 实现严格的嵌入模型验证和索引重建机制 - 添加前端聊天界面的知识库检索开关 - 实现搜索历史记录功能和错误降级处理 - 更新模型路由设置提示信息以反映索引重建需求
90 lines
4.9 KiB
Vue
90 lines
4.9 KiB
Vue
<script setup lang="ts">
|
||
import { ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import type { SearchResult } from '@/contracts'
|
||
import { useEditorStore } from '@/stores/editor'
|
||
import { useSearchStore } from '@/stores/search'
|
||
import { useWorkspaceStore } from '@/stores/workspace'
|
||
|
||
const searchStore = useSearchStore()
|
||
const workspaceStore = useWorkspaceStore()
|
||
const editorStore = useEditorStore()
|
||
const router = useRouter()
|
||
const folder = ref('')
|
||
const tag = ref('')
|
||
|
||
function submitSearch() {
|
||
void searchStore.doSearch({
|
||
query: searchStore.query,
|
||
mode: searchStore.mode,
|
||
folder: folder.value || undefined,
|
||
tag: tag.value || undefined,
|
||
})
|
||
}
|
||
|
||
async function openResult(result: SearchResult) {
|
||
await editorStore.loadFile(result.file_path)
|
||
workspaceStore.openFile(result.file_path)
|
||
editorStore.highlightBlock(result.block_id)
|
||
await router.push('/workspace')
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section class="feature-page search-page">
|
||
<header class="feature-header">
|
||
<div><h1>搜索知识库</h1><p>在当前 Vault 中进行全文、向量或混合检索。</p></div>
|
||
</header>
|
||
<form class="search-form panel" @submit.prevent="submitSearch">
|
||
<input v-model="searchStore.query" class="input search-input" placeholder="搜索笔记内容、标题或标签" autofocus />
|
||
<button class="button-primary" :disabled="!searchStore.query.trim() || searchStore.isSearching">
|
||
{{ searchStore.isSearching ? '搜索中…' : '搜索' }}
|
||
</button>
|
||
<div class="form-grid advanced">
|
||
<div class="field"><label>文件夹范围</label><input v-model="folder" class="input" placeholder="例如 /数据结构" /></div>
|
||
<div class="field"><label>标签</label><input v-model="tag" class="input" placeholder="例如 算法" /></div>
|
||
</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>
|
||
</div>
|
||
<div v-if="searchStore.results.length" class="result-list">
|
||
<article v-for="result in searchStore.results" :key="`${result.note_id}:${result.block_id}`"
|
||
class="item-card result-card" @click="openResult(result)">
|
||
<div class="result-title"><strong>{{ result.note_title }}</strong><span class="badge">{{ result.match_type }}</span></div>
|
||
<p class="subtle">{{ result.file_path }} · {{ result.heading_path }}</p>
|
||
<p class="snippet">{{ result.snippet }}</p>
|
||
<div class="result-meta"><span>相关度 {{ Math.round(result.score * 100) }}%</span><span>点击定位原文 →</span></div>
|
||
</article>
|
||
</div>
|
||
<div v-else-if="!searchStore.isSearching" class="empty-state">
|
||
<div><strong>{{ searchStore.query ? '没有找到匹配内容' : '从你的知识库开始搜索' }}</strong><p>可切换检索模式或缩小文件夹、标签范围。</p></div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.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); }
|
||
.result-list { display: grid; gap: var(--space-md); }
|
||
.result-card { position: relative; cursor: pointer; overflow: hidden; }
|
||
.result-card::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--color-accent-primary); opacity: 0; transform: scaleY(.45); transition: opacity var(--motion-fast), transform var(--motion-fast); }
|
||
.result-card:hover::before { opacity: 1; transform: scaleY(1); }
|
||
.snippet { margin: var(--space-md) 0; line-height: var(--line-height-relaxed); }
|
||
.result-meta { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||
@media (max-width: 700px) { .search-form { grid-template-columns: 1fr; } .advanced { grid-column: auto; } }
|
||
</style>
|